├── app ├── .gitignore ├── proguard-rules.pro ├── src │ └── main │ │ ├── res │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.webp │ │ ├── mipmap-anydpi-v26 │ │ │ └── ic_launcher.xml │ │ └── drawable │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ └── dev │ │ │ └── brahmkshatriya │ │ │ └── echo │ │ │ └── link │ │ │ └── Opener.kt │ │ └── AndroidManifest.xml └── build.gradle.kts ├── ext ├── .gitignore ├── src │ ├── test │ │ └── java │ │ │ └── dev │ │ │ └── brahmkshatriya │ │ │ └── echo │ │ │ └── extension │ │ │ ├── .gitignore │ │ │ ├── MockedSettings.kt │ │ │ └── ExtensionUnitTest.kt │ └── main │ │ └── java │ │ └── dev │ │ └── brahmkshatriya │ │ └── echo │ │ └── extension │ │ ├── endpoints │ │ ├── YtContinuation.kt │ │ ├── EchoVisitorEndpoint.kt │ │ ├── EchoSongRelatedEndpoint.kt │ │ ├── EchoArtistMoreEndpoint.kt │ │ ├── EchoPlaylistContinuationEndpoint.kt │ │ ├── EchoLibraryEndPoint.kt │ │ ├── EchoEditPlaylistEndpoint.kt │ │ ├── EchoArtistEndpoint.kt │ │ ├── EchoLyricsEndPoint.kt │ │ ├── EchoSearchSuggestionsEndpoint.kt │ │ ├── EchoVideoEndpoint.kt │ │ ├── EchoSongFeedEndpoint.kt │ │ ├── EchoSearchEndpoint.kt │ │ ├── EchoPlaylistEndpoint.kt │ │ ├── GoogleAccountResponse.kt │ │ ├── EchoSongEndPoint.kt │ │ ├── MusicShelf.kt │ │ └── YoutubeiBrowseResponse.kt │ │ └── Convertors.kt └── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── jitpack.yml ├── README.md ├── settings.gradle.kts ├── gradle.properties ├── gradlew.bat ├── .github └── workflows │ └── build.yml ├── .run └── app.run.xml └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /ext/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -keep class kotlinx.serialization.** { *; } -------------------------------------------------------------------------------- /ext/src/test/java/dev/brahmkshatriya/echo/extension/.gitignore: -------------------------------------------------------------------------------- 1 | LibraryUnitTest.kt -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brahmkshatriya/echo-youtube-extension-old/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | .DS_Store 5 | /build 6 | /captures 7 | .externalNativeBuild 8 | .cxx 9 | local.properties 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brahmkshatriya/echo-youtube-extension-old/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk17 3 | before_install: 4 | - sdk install java 17.0.1-open 5 | - sdk use java 17.0.1-open 6 | install: 7 | - ./gradlew clean ext:assemble publishToMavenLocal -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Aug 31 20:30:06 IST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Youtube Extension for Echo 2 | 3 | This extension allows you to play youtube music on [Echo](https://github.com/brahmkshatriya/echo). 4 | It uses the [YTM-kt](https://github.com/toasterofbread/ytm-kt/) library to load everything. 5 | 6 | ### Big thanks to [Talo](https://github.com/toasterofbread) for making the library. -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | @Suppress("UnstableApiUsage") 10 | dependencyResolutionManagement { 11 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 12 | repositories { 13 | google() 14 | mavenCentral() 15 | maven { url = uri("https://jitpack.io") } 16 | } 17 | } 18 | 19 | val extName: String by settings 20 | rootProject.name = extName 21 | include(":app") 22 | include(":ext") 23 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/YtContinuation.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class YtContinuation( 7 | val onResponseReceivedActions: List 8 | ) { 9 | @Serializable 10 | data class OnResponseReceivedAction( 11 | val appendContinuationItemsAction: AppendContinuationItemsAction 12 | ) 13 | 14 | @Serializable 15 | data class AppendContinuationItemsAction( 16 | val continuationItems: List 17 | ) 18 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | android.useAndroidX=true 3 | kotlin.code.style=official 4 | android.nonTransitiveRClass=true 5 | 6 | libVersion=e11d0c70d4 7 | 8 | extType=music 9 | extId=youtube-music 10 | extClass=YoutubeExtension 11 | 12 | extIconUrl=https://music.youtube.com/img/favicon_144.png 13 | extName=Youtube Music 14 | extDescription=Youtube Music Extension for Echo, with the help of YTM-kt library. 15 | 16 | extAuthor=Echo 17 | extAuthorUrl=https://github.com/brahmkshatriya/Echo 18 | 19 | extRepoUrl=https://github.com/brahmkshatriya/echo-youtube-extension 20 | extUpdateUrl=https://api.github.com/repos/brahmkshatriya/echo-youtube-extension/releases 21 | -------------------------------------------------------------------------------- /ext/src/test/java/dev/brahmkshatriya/echo/extension/MockedSettings.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension 2 | 3 | import dev.brahmkshatriya.echo.common.settings.Settings 4 | 5 | class MockedSettings : Settings { 6 | override fun getBoolean(key: String): Boolean? = null 7 | override fun getInt(key: String): Int? = null 8 | override fun getString(key: String): String? = null 9 | override fun getStringSet(key: String): Set? = null 10 | override fun putBoolean(key: String, value: Boolean?) {} 11 | override fun putInt(key: String, value: Int?) {} 12 | override fun putString(key: String, value: String?) {} 13 | override fun putStringSet(key: String, value: Set?) {} 14 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoVisitorEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 4 | import dev.toastbits.ytmkt.model.ApiEndpoint 5 | import io.ktor.client.call.body 6 | import io.ktor.client.request.request 7 | import io.ktor.client.statement.HttpResponse 8 | import kotlinx.serialization.Serializable 9 | 10 | class EchoVisitorEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 11 | suspend fun getVisitorId(): String { 12 | val response: HttpResponse = api.client.request { 13 | endpointPath("visitor_id") 14 | addApiHeadersWithAuthenticated() 15 | postWithBody() 16 | } 17 | 18 | val data: VisitorIdResponse = response.body() 19 | return data.responseContext.visitorData 20 | } 21 | 22 | @Serializable 23 | private data class VisitorIdResponse(val responseContext: ResponseContext) { 24 | @Serializable 25 | data class ResponseContext(val visitorData: String) 26 | } 27 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoSongRelatedEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 4 | import dev.toastbits.ytmkt.model.ApiEndpoint 5 | import io.ktor.client.call.body 6 | import io.ktor.client.request.request 7 | import io.ktor.client.statement.HttpResponse 8 | 9 | open class EchoSongRelatedEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 10 | 11 | suspend fun loadFromPlaylist(token: String) = runCatching { 12 | val response: HttpResponse = api.client.request { 13 | endpointPath("browse") 14 | url { 15 | parameters.append("ctoken", token) 16 | parameters.append("continuation", token) 17 | parameters.append("type", "next") 18 | } 19 | addApiHeadersWithAuthenticated() 20 | postWithBody() 21 | } 22 | 23 | val data: YoutubeiBrowseResponse = response.body() 24 | val contents = 25 | data.continuationContents?.sectionListContinuation?.contents 26 | contents?.let { EchoSongFeedEndpoint.processRows(it, api) } ?: emptyList() 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/dev/brahmkshatriya/echo/link/Opener.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.link 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | 8 | 9 | class Opener : Activity() { 10 | 11 | private val extensionId = "youtube-musicApp" 12 | 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | val uri = intent.data 16 | if (uri != null) { 17 | val type = uri.pathSegments[0] 18 | val path = when (type) { 19 | "channel" -> { 20 | val channelId = uri.pathSegments[1] ?: return 21 | "artist/$channelId" 22 | } 23 | 24 | "playlist" -> { 25 | val playlistId = uri.getQueryParameter("list") ?: return 26 | "playlist/$playlistId" 27 | } 28 | 29 | "browse" -> { 30 | val browseId = uri.pathSegments[1] ?: return 31 | "album/$browseId" 32 | } 33 | 34 | "watch" -> { 35 | val videoId = uri.getQueryParameter("v") ?: return 36 | "track/$videoId" 37 | } 38 | 39 | else -> return 40 | } 41 | val uriString = "echo://music/$extensionId/$path" 42 | startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uriString))) 43 | finishAndRemoveTask() 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoArtistMoreEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.brahmkshatriya.echo.extension.endpoints.EchoSongFeedEndpoint.Companion.clientContext 4 | import dev.brahmkshatriya.echo.extension.endpoints.EchoSongFeedEndpoint.Companion.processRows 5 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 6 | import dev.toastbits.ytmkt.model.ApiEndpoint 7 | import dev.toastbits.ytmkt.model.external.YoutubePage 8 | import io.ktor.client.call.body 9 | import io.ktor.client.request.request 10 | import io.ktor.client.statement.HttpResponse 11 | import kotlinx.serialization.json.put 12 | 13 | class EchoArtistMoreEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 14 | 15 | suspend fun load(param: YoutubePage.BrowseParamsData) = run { 16 | val response: HttpResponse = api.client.request { 17 | endpointPath("browse") 18 | addApiHeadersWithAuthenticated() 19 | postWithBody(clientContext) { 20 | put("browseId", param.browse_id) 21 | put("params", param.browse_params) 22 | } 23 | } 24 | val data: YoutubeiBrowseResponse = response.body() 25 | val contents = 26 | data.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() 27 | ?.tabRenderer?.content?.sectionListRenderer?.contents 28 | ?: data.continuationContents?.sectionListContinuation?.contents 29 | contents?.let { processRows(it, api) } ?: emptyList() 30 | } 31 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoPlaylistContinuationEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 4 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiPostBody 5 | import dev.toastbits.ytmkt.model.ApiEndpoint 6 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmSong 7 | import io.ktor.client.call.body 8 | import io.ktor.client.request.request 9 | 10 | class EchoPlaylistContinuationEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 11 | suspend fun load( 12 | ctoken: String? 13 | ): Triple?, List?, String?> = run { 14 | val response = api.client.request { 15 | endpointPath("browse") 16 | addApiHeadersWithAuthenticated() 17 | if (ctoken != null) { 18 | url.parameters.append("ctoken", ctoken) 19 | url.parameters.append("continuation", ctoken) 20 | url.parameters.append("type", "next") 21 | } 22 | postWithBody(YoutubeiPostBody.BASE.getPostBody(api)) 23 | } 24 | val parsed = response.body().onResponseReceivedActions.first() 25 | .appendContinuationItemsAction.continuationItems 26 | 27 | val cont = parsed.lastOrNull() 28 | ?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token 29 | val items = parsed.mapNotNull { it.toMediaItemData(api.data_language, api) } 30 | 31 | Triple( 32 | items.map { it.first }.filterIsInstance(), 33 | items.mapNotNull { it.second }, 34 | cont 35 | ) 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 12 | 15 | 16 | 19 | 22 | 23 | 26 | 29 | 32 | 33 | 36 | 39 | 40 | 43 | 46 | 47 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoLibraryEndPoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 4 | import dev.toastbits.ytmkt.model.ApiEndpoint 5 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmMediaItem 6 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmPlaylist 7 | import io.ktor.client.call.body 8 | import io.ktor.client.request.request 9 | import io.ktor.client.statement.HttpResponse 10 | import kotlinx.serialization.json.put 11 | 12 | class EchoLibraryEndPoint(override val api: YoutubeiApi) : ApiEndpoint() { 13 | suspend fun loadLibraryFeed( 14 | id: String, ctoken: String? = null 15 | ): Pair, String?> = run { 16 | val hl: String = api.data_language 17 | val response: HttpResponse = api.client.request { 18 | endpointPath("browse") 19 | if (ctoken != null) { 20 | url.parameters.append("ctoken", ctoken) 21 | url.parameters.append("continuation", ctoken) 22 | url.parameters.append("type", "next") 23 | } 24 | addApiHeadersWithAuthenticated() 25 | postWithBody { 26 | put("browseId", id) 27 | } 28 | } 29 | val data: YoutubeiBrowseResponse = response.body() 30 | 31 | val contents = 32 | data.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents 33 | val list = contents?.map { content -> 34 | val musicShelf = 35 | content.musicShelfRenderer ?: data.continuationContents?.musicShelfContinuation 36 | val grid = content.gridRenderer?.items 37 | val items = musicShelf?.contents ?: grid ?: emptyList() 38 | 39 | items.mapNotNull { contentsItem -> 40 | val item: YtmMediaItem? = contentsItem.toMediaItemData(hl, api)?.first 41 | if (item is YtmPlaylist) { 42 | if (contentsItem.musicTwoRowItemRenderer?.navigationEndpoint?.browseEndpoint == null) { 43 | return@mapNotNull null 44 | } 45 | contentsItem.musicTwoRowItemRenderer.menu?.menuRenderer?.items 46 | ?.findLast { it.menuNavigationItemRenderer?.icon?.iconType == "DELETE" } 47 | ?.let { return@mapNotNull item.copy(owner_id = api.user_auth_state?.own_channel_id) } 48 | } 49 | item 50 | } 51 | }?.flatten() ?: emptyList() 52 | val continuation = 53 | contents?.lastOrNull()?.musicShelfRenderer?.continuations?.firstOrNull()?.nextContinuationData?.continuation 54 | return list to continuation 55 | } 56 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoEditPlaylistEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 4 | import dev.toastbits.ytmkt.model.ApiEndpoint 5 | import io.ktor.client.call.body 6 | import io.ktor.client.request.request 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.json.JsonObject 9 | import kotlinx.serialization.json.buildJsonObject 10 | import kotlinx.serialization.json.put 11 | import kotlinx.serialization.json.putJsonArray 12 | 13 | class EchoEditPlaylistEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 14 | 15 | sealed class Action { 16 | data class Add(val id: String) : Action() 17 | data class Remove(val id: String, val setId: String) : Action() 18 | data class Move(val setId: String, val aboveVideoSetId: String?) : Action() 19 | } 20 | 21 | suspend fun editPlaylist(playlistId: String, actions: List) = run { 22 | api.client.request { 23 | endpointPath("browse/edit_playlist") 24 | addApiHeadersWithAuthenticated() 25 | postWithBody { 26 | put("playlistId", playlistId.removePrefix("VL")) 27 | putJsonArray("actions") { 28 | actions.forEach { 29 | add(getActionRequestData(it)) 30 | } 31 | } 32 | } 33 | }.body() 34 | } 35 | 36 | private fun getActionRequestData(action: Action): JsonObject { 37 | return when (action) { 38 | 39 | is Action.Add -> buildJsonObject { 40 | put("action", "ACTION_ADD_VIDEO") 41 | put("addedVideoId", action.id) 42 | put("dedupeOption", "DEDUPE_OPTION_SKIP") 43 | } 44 | 45 | is Action.Move -> buildJsonObject { 46 | put("action", "ACTION_MOVE_VIDEO_BEFORE") 47 | put("setVideoId", action.setId) 48 | action.aboveVideoSetId?.let { put("movedSetVideoIdSuccessor", it) } 49 | } 50 | 51 | is Action.Remove -> buildJsonObject { 52 | put("action", "ACTION_REMOVE_VIDEO") 53 | put("removedVideoId", action.id) 54 | put("setVideoId", action.setId) 55 | } 56 | } 57 | } 58 | 59 | @Serializable 60 | data class EditorResponse ( 61 | val playlistEditResults: List? 62 | ) 63 | 64 | @Serializable 65 | data class PlaylistEditResult ( 66 | val playlistEditVideoAddedResultData: PlaylistEditVideoAddedResultData 67 | ) 68 | 69 | @Serializable 70 | data class PlaylistEditVideoAddedResultData ( 71 | val videoId: String, 72 | val setVideoId: String, 73 | val multiSelectData: MultiSelectData 74 | ) 75 | 76 | @Serializable 77 | data class MultiSelectData ( 78 | val multiSelectParams: String, 79 | val multiSelectItem: String 80 | ) 81 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | env: 12 | NAME: Youtube Extension 13 | TAG: yt 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup JDK 17 19 | uses: actions/setup-java@v4 20 | with: 21 | distribution: 'zulu' 22 | java-version: 17 23 | cache: 'gradle' 24 | 25 | - name: Cook Env 26 | run: | 27 | echo -e "## ${{ env.NAME }}\n${{ github.event.head_commit.message }}" > commit.txt 28 | version=$( echo ${{ github.event.head_commit.id }} | cut -c1-7 ) 29 | echo "VERSION=v$version" >> $GITHUB_ENV 30 | echo "APP_PATH=app/build/${{ env.TAG }}-$version.eapk" >> $GITHUB_ENV 31 | echo "${{ secrets.KEYSTORE_B64 }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks 32 | chmod +x ./gradlew 33 | 34 | - name: Build with Gradle 35 | run: | 36 | ./gradlew assembleRelease \ 37 | -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \ 38 | -Pandroid.injected.signing.store.password=${{ secrets.PASSWORD }} \ 39 | -Pandroid.injected.signing.key.alias=key0 \ 40 | -Pandroid.injected.signing.key.password=${{ secrets.PASSWORD }} 41 | 42 | cp app/build/outputs/apk/release/app-release.apk ${{ env.APP_PATH }} 43 | 44 | - name: Upload APK 45 | uses: actions/upload-artifact@v4 46 | with: 47 | path: ${{ env.APP_PATH }} 48 | 49 | - name: Create Release 50 | uses: softprops/action-gh-release@v2 51 | with: 52 | make_latest: true 53 | tag_name: ${{ env.VERSION }} 54 | body_path: commit.txt 55 | name: ${{ env.VERSION }} 56 | files: ${{ env.APP_PATH }} 57 | 58 | - name: Delete Old Releases 59 | uses: sgpublic/delete-release-action@master 60 | with: 61 | release-drop: true 62 | release-keep-count: 1 63 | release-drop-tag: true 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | 67 | - name: Upload APK to Discord 68 | shell: bash 69 | env: 70 | WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 71 | run: | 72 | commit=$(jq -Rsa . <<< "${{ github.event.head_commit.message }}" | tail -c +2 | head -c -2) 73 | message=$(echo "@everyone **${{ env.VERSION }}**\n$commit") 74 | curl -F "payload_json={\"content\":\"${message}\"}" \ 75 | -F "file=@${{ env.APP_PATH }}" \ 76 | ${{ env.WEBHOOK }} 77 | 78 | - name: Upload APK to Telegram 79 | shell: bash 80 | env: 81 | BOT_ID: ${{ secrets.TELEGRAM_BOT_ID }} 82 | CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }} 83 | THREAD_ID: ${{ secrets.TELEGRAM_THREAD_ID }} 84 | run: | 85 | message=$(echo -e "${{ env.NAME }}\n$(echo -e "${{ github.event.head_commit.message }}" | sed 's/[~>|.!+-=#]/\\&/g')") 86 | curl -F "chat_id=${{ env.CHANNEL_ID }}" \ 87 | -F "message_thread_id=${{ env.THREAD_ID }}" \ 88 | -F "document=@${{ env.APP_PATH }}" \ 89 | -F "caption=${message}" \ 90 | -F "parse_mode=MarkdownV2" \ 91 | https://api.telegram.org/bot${{ env.BOT_ID }}/sendDocument 92 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoArtistEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.brahmkshatriya.echo.extension.endpoints.EchoSongFeedEndpoint.Companion.processRows 4 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 5 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiPostBody 6 | import dev.toastbits.ytmkt.model.ApiEndpoint 7 | import dev.toastbits.ytmkt.model.external.ItemLayoutType 8 | import dev.toastbits.ytmkt.model.external.ThumbnailProvider 9 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtist 10 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtistBuilder 11 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtistLayout 12 | import dev.toastbits.ytmkt.model.internal.HeaderRenderer 13 | import dev.toastbits.ytmkt.uistrings.parseYoutubeSubscribersString 14 | import io.ktor.client.call.body 15 | import io.ktor.client.request.request 16 | import io.ktor.client.statement.HttpResponse 17 | import kotlinx.serialization.json.put 18 | 19 | class EchoArtistEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 20 | 21 | suspend fun loadArtist(id: String): YtmArtist { 22 | val hl: String = api.data_language 23 | val response: HttpResponse = api.client.request { 24 | endpointPath("browse") 25 | addApiHeadersWithAuthenticated() 26 | postWithBody(YoutubeiPostBody.MOBILE.getPostBody(api)) { 27 | put("browseId", id) 28 | } 29 | } 30 | 31 | return parseArtistResponse(id, response, hl, api).getOrThrow() 32 | } 33 | 34 | private suspend fun parseArtistResponse( 35 | artistId: String, 36 | response: HttpResponse, 37 | hl: String, 38 | api: YoutubeiApi 39 | ): Result = runCatching { 40 | val parsed: YoutubeiBrowseResponse = response.body() 41 | val builder = YtmArtistBuilder(artistId) 42 | 43 | val headerRenderer: HeaderRenderer? = parsed.header?.getRenderer() 44 | if (headerRenderer != null) { 45 | builder.name = headerRenderer.title!!.first_text 46 | builder.description = headerRenderer.description?.first_text 47 | builder.thumbnail_provider = 48 | ThumbnailProvider.fromThumbnails(headerRenderer.getThumbnails()) 49 | 50 | headerRenderer.subscriptionButton?.subscribeButtonRenderer?.let { subscribeButton -> 51 | builder.subscribe_channel_id = subscribeButton.channelId 52 | builder.subscriber_count = parseYoutubeSubscribersString( 53 | subscribeButton.subscriberCountText.first_text, 54 | hl 55 | ) 56 | builder.subscribed = subscribeButton.subscribed 57 | } 58 | headerRenderer.playButton?.buttonRenderer?.let { 59 | if (it.icon?.iconType == "MUSIC_SHUFFLE") { 60 | builder.shuffle_playlist_id = it.navigationEndpoint.watchEndpoint?.playlistId 61 | } 62 | } 63 | } 64 | 65 | val shelfList = parsed.getShelves(false) 66 | builder.layouts = processRows(shelfList, api).map { 67 | YtmArtistLayout( 68 | items = it.items, 69 | title = it.title, 70 | type = ItemLayoutType.GRID, 71 | view_more = it.view_more, 72 | playlist_id = null 73 | ) 74 | } 75 | 76 | return@runCatching builder.build() 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoLyricsEndPoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.brahmkshatriya.echo.extension.endpoints.EchoSongFeedEndpoint.Companion.clientContext 4 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 5 | import dev.toastbits.ytmkt.model.ApiEndpoint 6 | import io.ktor.client.call.body 7 | import io.ktor.client.request.request 8 | import kotlinx.serialization.Serializable 9 | import kotlinx.serialization.json.put 10 | 11 | class EchoLyricsEndPoint(override val api: YoutubeiApi) : ApiEndpoint() { 12 | suspend fun getLyrics(id: String): Pair, String?>? { 13 | val response = api.client.request { 14 | endpointPath("browse") 15 | addApiHeadersWithoutAuthentication() 16 | postWithBody(clientContext) { 17 | put("browseId", id) 18 | } 19 | } 20 | val data = response.body() 21 | return data.contents?.elementRenderer?.newElement?.type 22 | ?.componentType?.model?.timedLyricsModel?.lyricsData?.run { 23 | timedLyricsData to sourceMessage 24 | } 25 | } 26 | 27 | } 28 | 29 | @Serializable 30 | data class LyricsResponse( 31 | val contents: Contents? = null 32 | ) 33 | 34 | @Serializable 35 | data class Contents( 36 | val elementRenderer: ElementRenderer? = null 37 | ) 38 | 39 | @Serializable 40 | data class ElementRenderer( 41 | val trackingParams: String? = null, 42 | val newElement: NewElement? = null 43 | ) 44 | 45 | @Serializable 46 | data class NewElement( 47 | val type: Type? = null 48 | ) 49 | 50 | @Serializable 51 | data class Type( 52 | val componentType: ComponentType? = null 53 | ) 54 | 55 | @Serializable 56 | data class ComponentType( 57 | val model: Model? = null 58 | ) 59 | 60 | @Serializable 61 | data class Model( 62 | val timedLyricsModel: TimedLyricsModel? = null 63 | ) 64 | 65 | @Serializable 66 | data class TimedLyricsModel( 67 | val lyricsData: LyricsData? = null 68 | ) 69 | 70 | @Serializable 71 | data class LyricsData( 72 | val timedLyricsData: List, 73 | val sourceMessage: String? = null, 74 | val trackingParams: String? = null, 75 | val disableTapToSeek: Boolean? = null, 76 | val loggingCommand: Command? = null, 77 | val colorSamplePaletteEntityKey: String? = null, 78 | val backgroundImage: BackgroundImage? = null, 79 | val enableDirectUpdateProperties: Boolean? = null, 80 | val timedLyricsCommand: Command? = null, 81 | val collectionKey: String? = null 82 | ) 83 | 84 | @Serializable 85 | data class BackgroundImage( 86 | val sources: List? = null 87 | ) 88 | 89 | @Serializable 90 | data class Source( 91 | val url: String? = null 92 | ) 93 | 94 | @Serializable 95 | data class Command( 96 | val clickTrackingParams: String? = null, 97 | val logLyricEventCommand: LogLyricEventCommand? = null 98 | ) 99 | 100 | @Serializable 101 | data class LogLyricEventCommand( 102 | val serializedLyricInfo: String? = null 103 | ) 104 | 105 | @Serializable 106 | data class TimedLyricsDatum( 107 | val lyricLine: String, 108 | val cueRange: CueRange 109 | ) 110 | 111 | @Serializable 112 | data class CueRange( 113 | val startTimeMilliseconds: String, 114 | val endTimeMilliseconds: String, 115 | val metadata: Metadata? = null 116 | ) 117 | 118 | @Serializable 119 | data class Metadata( 120 | val id: String? = null 121 | ) 122 | -------------------------------------------------------------------------------- /.run/app.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 69 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoSearchSuggestionsEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.brahmkshatriya.echo.common.models.QuickSearchItem 4 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 5 | import dev.toastbits.ytmkt.model.ApiEndpoint 6 | import io.ktor.client.call.body 7 | import io.ktor.client.request.request 8 | import io.ktor.client.statement.HttpResponse 9 | import kotlinx.serialization.Serializable 10 | import kotlinx.serialization.json.add 11 | import kotlinx.serialization.json.buildJsonArray 12 | import kotlinx.serialization.json.put 13 | 14 | open class EchoSearchSuggestionsEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 15 | 16 | suspend fun get( 17 | query: String 18 | ): Result> = runCatching { 19 | val response: HttpResponse = api.client.request { 20 | endpointPath("music/get_search_suggestions") 21 | addApiHeadersWithAuthenticated() 22 | postWithBody { 23 | put("input", query) 24 | } 25 | } 26 | 27 | val parsed: YoutubeiSearchSuggestionsResponse = response.body() 28 | 29 | val suggestions = parsed.getSuggestions() 30 | ?: throw NullPointerException("Suggestions is null ($parsed)") 31 | 32 | return@runCatching suggestions 33 | } 34 | 35 | suspend fun delete(query: QuickSearchItem.Query) { 36 | val feedbackToken = query.extras["token"] ?: return 37 | runCatching { 38 | api.client.request { 39 | endpointPath("feedback") 40 | addApiHeadersWithAuthenticated() 41 | postWithBody { 42 | put("feedbackTokens", buildJsonArray { add(feedbackToken) }) 43 | put("isFeedbackTokenUnencrypted", false) 44 | put("shouldMerge", false) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | @Serializable 52 | private data class YoutubeiSearchSuggestionsResponse( 53 | val contents: List? 54 | ) { 55 | fun getSuggestions() = contents?.firstOrNull() 56 | ?.searchSuggestionsSectionRenderer?.contents?.mapNotNull { suggestion -> 57 | val query = suggestion.searchSuggestionRenderer?.navigationEndpoint 58 | ?.searchEndpoint?.query ?: return@mapNotNull null 59 | return@mapNotNull if (suggestion.historySuggestionRenderer != null) { 60 | return@mapNotNull QuickSearchItem.Query( 61 | query, 62 | true, 63 | suggestion.historySuggestionRenderer.serviceEndpoint 64 | ?.feedbackEndpoint?.feedbackToken?.let { 65 | mapOf("token" to it) 66 | } ?: emptyMap() 67 | ) 68 | } else QuickSearchItem.Query(query, false) 69 | } 70 | 71 | @Serializable 72 | data class Content(val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer?) 73 | 74 | @Serializable 75 | data class SearchSuggestionsSectionRenderer(val contents: List) 76 | 77 | @Serializable 78 | data class Suggestion( 79 | val searchSuggestionRenderer: SearchSuggestionRenderer?, 80 | val historySuggestionRenderer: SearchSuggestionRenderer? 81 | ) 82 | 83 | @Serializable 84 | data class SearchSuggestionRenderer( 85 | val navigationEndpoint: NavigationEndpoint, 86 | val serviceEndpoint: ServiceEndpoint? = null 87 | ) 88 | 89 | @Serializable 90 | data class ServiceEndpoint(val feedbackEndpoint: FeedbackEndpoint) 91 | 92 | @Serializable 93 | data class FeedbackEndpoint(val feedbackToken: String) 94 | 95 | @Serializable 96 | data class NavigationEndpoint(val searchEndpoint: SearchEndpoint) 97 | 98 | @Serializable 99 | data class SearchEndpoint(val query: String) 100 | } 101 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.IOException 2 | 3 | plugins { 4 | id("com.android.application") 5 | id("org.jetbrains.kotlin.android") 6 | } 7 | 8 | dependencies { 9 | implementation(project(":ext")) 10 | val libVersion: String by project 11 | compileOnly("com.github.brahmkshatriya:echo:$libVersion") 12 | compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.1.0") 13 | } 14 | 15 | val extType: String by project 16 | val extId: String by project 17 | val extClass: String by project 18 | 19 | val extIconUrl: String? by project 20 | val extName: String by project 21 | val extDescription: String? by project 22 | 23 | val extAuthor: String by project 24 | val extAuthorUrl: String? by project 25 | 26 | val extRepoUrl: String? by project 27 | val extUpdateUrl: String? by project 28 | 29 | val gitHash = execute("git", "rev-parse", "HEAD").take(7) 30 | val gitCount = execute("git", "rev-list", "--count", "HEAD").toInt() 31 | val verCode = gitCount 32 | val verName = "v$gitHash" 33 | 34 | 35 | val outputDir = file("${layout.buildDirectory.asFile.get()}/generated/proguard") 36 | val generatedProguard = file("${outputDir}/generated-rules.pro") 37 | 38 | tasks.register("generateProguardRules") { 39 | doLast { 40 | outputDir.mkdirs() 41 | generatedProguard.writeText( 42 | "-dontobfuscate\n-keep,allowoptimization class dev.brahmkshatriya.echo.extension.$extClass" 43 | ) 44 | } 45 | } 46 | 47 | tasks.named("preBuild") { 48 | dependsOn("generateProguardRules") 49 | } 50 | 51 | tasks.register("uninstall") { 52 | android.run { 53 | execute( 54 | adbExecutable.absolutePath, "shell", "pm", "uninstall", defaultConfig.applicationId!! 55 | ) 56 | } 57 | } 58 | 59 | android { 60 | namespace = "dev.brahmkshatriya.echo.extension" 61 | compileSdk = 35 62 | defaultConfig { 63 | applicationId = "dev.brahmkshatriya.echo.extension.ytm" 64 | minSdk = 24 65 | targetSdk = 35 66 | 67 | manifestPlaceholders.apply { 68 | put("type", "dev.brahmkshatriya.echo.${extType}") 69 | put("id", extId) 70 | put("class_path", "dev.brahmkshatriya.echo.extension.${extClass}") 71 | put("version", verName) 72 | put("version_code", verCode.toString()) 73 | put("icon_url", extIconUrl ?: "") 74 | put("app_name", "Echo : $extName Extension") 75 | put("name", extName) 76 | put("description", extDescription ?: "") 77 | put("author", extAuthor) 78 | put("author_url", extAuthorUrl ?: "") 79 | put("repo_url", extRepoUrl ?: "") 80 | put("update_url", extUpdateUrl ?: "") 81 | } 82 | } 83 | 84 | buildTypes { 85 | all { 86 | isMinifyEnabled = true 87 | proguardFiles( 88 | getDefaultProguardFile("proguard-android-optimize.txt"), 89 | generatedProguard.absolutePath, 90 | "proguard-rules.pro" 91 | ) 92 | signingConfig = signingConfigs.getByName("debug") 93 | } 94 | } 95 | 96 | compileOptions { 97 | sourceCompatibility = JavaVersion.VERSION_17 98 | targetCompatibility = JavaVersion.VERSION_17 99 | } 100 | 101 | kotlinOptions { 102 | jvmTarget = JavaVersion.VERSION_17.toString() 103 | } 104 | } 105 | 106 | fun execute(vararg command: String): String { 107 | val process = ProcessBuilder(*command) 108 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 109 | .redirectError(ProcessBuilder.Redirect.PIPE) 110 | .start() 111 | 112 | val output = process.inputStream.bufferedReader().readText() 113 | val errorOutput = process.errorStream.bufferedReader().readText() 114 | 115 | val exitCode = process.waitFor() 116 | 117 | if (exitCode != 0) { 118 | throw IOException( 119 | "Command failed with exit code $exitCode. Command: ${command.joinToString(" ")}\n" + 120 | "Stdout:\n$output\n" + 121 | "Stderr:\n$errorOutput" 122 | ) 123 | } 124 | 125 | return output.trim() 126 | } -------------------------------------------------------------------------------- /ext/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 2 | import java.io.IOException 3 | 4 | plugins { 5 | id("java-library") 6 | id("org.jetbrains.kotlin.jvm") 7 | id("maven-publish") 8 | id("com.gradleup.shadow") version "8.3.0" 9 | kotlin("plugin.serialization") version "1.9.22" 10 | } 11 | 12 | java { 13 | sourceCompatibility = JavaVersion.VERSION_17 14 | targetCompatibility = JavaVersion.VERSION_17 15 | } 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | } 20 | 21 | fun T.excludeKotlin() { 22 | exclude("org.jetbrains.kotlin", "kotlin-stdlib") 23 | exclude("org.jetbrains.kotlinx", "kotlinx-coroutines-core") 24 | } 25 | 26 | dependencies { 27 | val libVersion: String by project 28 | compileOnly("com.github.brahmkshatriya:echo:$libVersion") 29 | compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.1.0") 30 | 31 | implementation("dev.toastbits.ytmkt:ytmkt:0.3.2") { excludeKotlin() } 32 | val ktorVersion = "3.0.0-beta-2" 33 | implementation("io.ktor:ktor-client-core:$ktorVersion") { excludeKotlin() } 34 | implementation("io.ktor:ktor-client-cio:$ktorVersion") { excludeKotlin() } 35 | implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") { excludeKotlin() } 36 | implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") { excludeKotlin() } 37 | 38 | testImplementation("junit:junit:4.13.2") 39 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") 40 | testImplementation("com.github.brahmkshatriya:echo:$libVersion") 41 | } 42 | 43 | // Extension properties goto `gradle.properties` to set values 44 | 45 | val extType: String by project 46 | val extId: String by project 47 | val extClass: String by project 48 | 49 | val extIconUrl: String? by project 50 | val extName: String by project 51 | val extDescription: String? by project 52 | 53 | val extAuthor: String by project 54 | val extAuthorUrl: String? by project 55 | 56 | val extRepoUrl: String? by project 57 | val extUpdateUrl: String? by project 58 | 59 | val gitHash = execute("git", "rev-parse", "HEAD").take(7) 60 | val gitCount = execute("git", "rev-list", "--count", "HEAD").toInt() 61 | val verCode = gitCount 62 | val verName = "v$gitHash" 63 | 64 | publishing { 65 | publications { 66 | create("mavenJava") { 67 | groupId = "dev.brahmkshatriya.echo.extension" 68 | artifactId = extId 69 | version = verName 70 | 71 | from(components["java"]) 72 | } 73 | } 74 | } 75 | 76 | tasks { 77 | val shadowJar by getting(ShadowJar::class) { 78 | archiveBaseName.set(extId) 79 | archiveVersion.set(verName) 80 | manifest { 81 | attributes( 82 | mapOf( 83 | "Extension-Id" to extId, 84 | "Extension-Type" to extType, 85 | "Extension-Class" to extClass, 86 | 87 | "Extension-Version-Code" to verCode, 88 | "Extension-Version-Name" to verName, 89 | 90 | "Extension-Icon-Url" to extIconUrl, 91 | "Extension-Name" to extName, 92 | "Extension-Description" to extDescription, 93 | 94 | "Extension-Author" to extAuthor, 95 | "Extension-Author-Url" to extAuthorUrl, 96 | 97 | "Extension-Repo-Url" to extRepoUrl, 98 | "Extension-Update-Url" to extUpdateUrl 99 | ) 100 | ) 101 | } 102 | } 103 | } 104 | 105 | fun execute(vararg command: String): String { 106 | val process = ProcessBuilder(*command) 107 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 108 | .redirectError(ProcessBuilder.Redirect.PIPE) 109 | .start() 110 | 111 | val output = process.inputStream.bufferedReader().readText() 112 | val errorOutput = process.errorStream.bufferedReader().readText() 113 | 114 | val exitCode = process.waitFor() 115 | 116 | if (exitCode != 0) { 117 | throw IOException( 118 | "Command failed with exit code $exitCode. Command: ${command.joinToString(" ")}\n" + 119 | "Stdout:\n$output\n" + 120 | "Stderr:\n$errorOutput" 121 | ) 122 | } 123 | 124 | return output.trim() 125 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoVideoEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 4 | import dev.toastbits.ytmkt.model.ApiEndpoint 5 | import io.ktor.client.call.body 6 | import io.ktor.client.request.request 7 | import io.ktor.client.statement.HttpResponse 8 | import kotlinx.coroutines.async 9 | import kotlinx.coroutines.coroutineScope 10 | import kotlinx.serialization.Serializable 11 | import kotlinx.serialization.json.JsonObject 12 | import kotlinx.serialization.json.buildJsonObject 13 | import kotlinx.serialization.json.put 14 | 15 | class EchoVideoEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 16 | 17 | private suspend fun request( 18 | context: JsonObject, 19 | id: String, 20 | playlist: String? = null 21 | ): HttpResponse { 22 | return api.client.request { 23 | endpointPath("player") 24 | addApiHeadersWithoutAuthentication() 25 | postWithBody(context) { 26 | put("videoId", id) 27 | put("playlistId", playlist) 28 | } 29 | } 30 | } 31 | 32 | suspend fun getVideo(resolve: Boolean, id: String, playlist: String? = null) = coroutineScope { 33 | val web = async { 34 | if (resolve) request(webRemix, id, playlist) 35 | .body().videoDetails.musicVideoType 36 | else null 37 | } 38 | val ios = request(iosContext(), id, playlist).body() 39 | ios to web.await() 40 | } 41 | 42 | private fun iosContext() = buildJsonObject { 43 | put("context", buildJsonObject { 44 | put("client", buildJsonObject { 45 | put("clientName", "IOS") 46 | put("clientVersion", "19.34.2") 47 | put("visitorData", api.visitor_id) 48 | }) 49 | }) 50 | } 51 | 52 | private val webRemix = buildJsonObject { 53 | put("context", buildJsonObject { 54 | put("client", buildJsonObject { 55 | put("clientName", "WEB_REMIX") 56 | put("clientVersion", "1.20220606.03.00") 57 | }) 58 | }) 59 | } 60 | } 61 | 62 | @Serializable 63 | data class YoutubeFormatResponse( 64 | val streamingData: StreamingData, 65 | val videoDetails: VideoDetails 66 | ) 67 | 68 | @Serializable 69 | data class StreamingData( 70 | val expiresInSeconds: String, 71 | val hlsManifestUrl: String?, 72 | val adaptiveFormats: List 73 | ) 74 | 75 | @Serializable 76 | data class AdaptiveFormat( 77 | val itag: Long? = null, 78 | val url: String? = null, 79 | val mimeType: String, 80 | val bitrate: Int, 81 | val width: Long? = null, 82 | val height: Long? = null, 83 | val initRange: Range? = null, 84 | val indexRange: Range? = null, 85 | val lastModified: String? = null, 86 | val contentLength: String? = null, 87 | val quality: String? = null, 88 | val fps: Long? = null, 89 | val qualityLabel: String? = null, 90 | val projectionType: String? = null, 91 | val averageBitrate: Long? = null, 92 | val approxDurationMs: String? = null, 93 | val colorInfo: ColorInfo? = null, 94 | val highReplication: Boolean? = null, 95 | val audioQuality: String? = null, 96 | val audioSampleRate: String? = null, 97 | val audioChannels: Long? = null, 98 | val loudnessDb: Double? = null 99 | ) 100 | 101 | @Serializable 102 | data class ColorInfo( 103 | val primaries: String? = null, 104 | val transferCharacteristics: String? = null, 105 | val matrixCoefficients: String? = null 106 | ) 107 | 108 | @Serializable 109 | data class Range( 110 | val start: String? = null, 111 | val end: String? = null 112 | ) 113 | 114 | @Serializable 115 | data class VideoDetails( 116 | val videoId: String, 117 | val title: String?, 118 | val lengthSeconds: String, 119 | val channelId: String, 120 | val isOwnerViewing: Boolean? = null, 121 | val isCrawlable: Boolean? = null, 122 | val allowRatings: Boolean? = null, 123 | val viewCount: String? = null, 124 | val author: String, 125 | val isPrivate: Boolean? = null, 126 | val isUnpluggedCorpus: Boolean? = null, 127 | val musicVideoType: String? = null, 128 | val isLiveContent: Boolean? = null, 129 | val shortDescription: String? = null, 130 | ) -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoSongFeedEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.endpoint.SongFeedFilterChip 4 | import dev.toastbits.ytmkt.endpoint.SongFeedLoadResult 5 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 6 | import dev.toastbits.ytmkt.model.ApiEndpoint 7 | import dev.toastbits.ytmkt.model.external.MediaItemYoutubePage 8 | import dev.toastbits.ytmkt.model.external.PlainYoutubePage 9 | import dev.toastbits.ytmkt.model.external.mediaitem.MediaItemLayout 10 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmMediaItem 11 | import dev.toastbits.ytmkt.model.internal.YoutubeiHeaderContainer 12 | import dev.toastbits.ytmkt.uistrings.RawUiString 13 | import dev.toastbits.ytmkt.uistrings.YoutubeUiString 14 | import io.ktor.client.call.body 15 | import io.ktor.client.request.request 16 | import io.ktor.client.statement.HttpResponse 17 | import kotlinx.serialization.json.buildJsonObject 18 | import kotlinx.serialization.json.put 19 | 20 | val PLAIN_HEADERS: List = 21 | listOf("accept-language", "user-agent", "accept-encoding", "content-encoding", "origin") 22 | 23 | open class EchoSongFeedEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 24 | 25 | suspend fun getSongFeed( 26 | minRows: Int = -1, 27 | params: String? = null, 28 | continuation: String? = null, 29 | browseId: String? = null 30 | ) = runCatching { 31 | val hl: String = api.data_language 32 | 33 | suspend fun performRequest(ctoken: String?): YoutubeiBrowseResponse { 34 | val response: HttpResponse = api.client.request { 35 | endpointPath("browse") 36 | 37 | if (ctoken != null) { 38 | url.parameters.append("ctoken", ctoken) 39 | url.parameters.append("continuation", ctoken) 40 | url.parameters.append("type", "next") 41 | } 42 | 43 | addApiHeadersWithAuthenticated() 44 | addApiHeadersWithoutAuthentication(PLAIN_HEADERS) 45 | postWithBody { 46 | if (params != null) { 47 | put("params", params) 48 | } 49 | if (browseId != null) { 50 | put("browseId", browseId) 51 | } 52 | } 53 | } 54 | return response.body() 55 | } 56 | 57 | var data: YoutubeiBrowseResponse = performRequest(continuation) 58 | val headerChips: List? = data.getHeaderChips(hl) 59 | 60 | val rows: MutableList = processRows( 61 | data.getShelves(continuation != null), api 62 | ).toMutableList() 63 | 64 | var ctoken: String? = data.ctoken 65 | while (ctoken != null && minRows >= 1 && rows.size < minRows) { 66 | data = performRequest(ctoken) 67 | ctoken = data.ctoken 68 | 69 | val shelves = data.getShelves(true) 70 | if (shelves.isEmpty()) { 71 | break 72 | } 73 | 74 | rows.addAll(processRows(shelves, api)) 75 | } 76 | 77 | return@runCatching SongFeedLoadResult(rows, ctoken, headerChips) 78 | } 79 | 80 | companion object { 81 | val clientContext = buildJsonObject { 82 | put("context", buildJsonObject { 83 | put("client", buildJsonObject { 84 | put("clientName", "26") 85 | put("clientVersion", "6.48.2") 86 | }) 87 | }) 88 | } 89 | 90 | fun processRows( 91 | rows: List, api: YoutubeiApi 92 | ): List { 93 | val hl = api.data_language 94 | fun String.createUiString() = 95 | YoutubeUiString.Type.HOME_FEED.createFromKey(this, api.data_language) 96 | 97 | return rows.mapNotNull { row -> 98 | val items = row.getMediaItems(hl, api) ?: return@mapNotNull null 99 | val default = MediaItemLayout(items, "".createUiString(), null, null, null) 100 | when (val renderer = row.getRenderer()) { 101 | is YoutubeiBrowseResponse.YoutubeiShelf.MusicShelfRenderer -> { 102 | val mediaItem = renderer.bottomEndpoint?.getMediaItem() 103 | MediaItemLayout( 104 | items, 105 | (renderer.title?.first_text ?: "").createUiString(), 106 | null, 107 | null, 108 | mediaItem?.let { renderer.bottomEndpoint.getViewMore(it) } 109 | ) 110 | } 111 | 112 | is YoutubeiHeaderContainer -> { 113 | val header = renderer.header?.header_renderer ?: return@mapNotNull default 114 | val titleTextRun = header.title ?: return@mapNotNull default 115 | val browseEndpoint = 116 | titleTextRun.runs?.first()?.navigationEndpoint?.browseEndpoint 117 | val browseId = browseEndpoint?.browseId 118 | val pageType = browseEndpoint?.browseEndpointContextSupportedConfigs 119 | ?.browseEndpointContextMusicConfig?.pageType 120 | val title = titleTextRun.first_text.createUiString() 121 | 122 | val subtitle = (header.subtitle ?: header.strapline)?.first_text?.let { 123 | RawUiString( 124 | it.lowercase().replaceFirstChar { char -> char.uppercase() }) 125 | } 126 | val page = when { 127 | browseId?.startsWith("FEmusic_") == true -> 128 | PlainYoutubePage(browseId) 129 | 130 | pageType != null && browseId != null -> { 131 | val mediaItem = 132 | YtmMediaItem.Type.fromBrowseEndpointType(pageType) 133 | ?.itemFromId(browseId) 134 | mediaItem?.let { MediaItemYoutubePage(it, browseEndpoint.params) } 135 | } 136 | 137 | else -> null 138 | } 139 | MediaItemLayout(items, title, subtitle, null, page) 140 | } 141 | 142 | else -> { 143 | println("Unknown shelf type: $renderer") 144 | default 145 | } 146 | } 147 | } 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/Convertors.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension 2 | 3 | import dev.brahmkshatriya.echo.common.helpers.PagedData 4 | import dev.brahmkshatriya.echo.common.models.Album 5 | import dev.brahmkshatriya.echo.common.models.Artist 6 | import dev.brahmkshatriya.echo.common.models.Date.Companion.toDate 7 | import dev.brahmkshatriya.echo.common.models.EchoMediaItem 8 | import dev.brahmkshatriya.echo.common.models.ImageHolder 9 | import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder 10 | import dev.brahmkshatriya.echo.common.models.Playlist 11 | import dev.brahmkshatriya.echo.common.models.Shelf 12 | import dev.brahmkshatriya.echo.common.models.Track 13 | import dev.brahmkshatriya.echo.common.models.User 14 | import dev.brahmkshatriya.echo.extension.YoutubeExtension.Companion.ENGLISH 15 | import dev.brahmkshatriya.echo.extension.YoutubeExtension.Companion.SINGLES 16 | import dev.brahmkshatriya.echo.extension.endpoints.GoogleAccountResponse 17 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 18 | import dev.toastbits.ytmkt.model.external.ThumbnailProvider 19 | import dev.toastbits.ytmkt.model.external.mediaitem.MediaItemLayout 20 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtist 21 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmMediaItem 22 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmPlaylist 23 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmSong 24 | import io.ktor.client.statement.HttpResponse 25 | import io.ktor.client.statement.bodyAsText 26 | import kotlinx.serialization.json.Json 27 | 28 | suspend fun MediaItemLayout.toShelf( 29 | api: YoutubeiApi, 30 | language: String, 31 | quality: ThumbnailProvider.Quality 32 | ): Shelf { 33 | val single = title?.getString(ENGLISH) == SINGLES 34 | return Shelf.Lists.Items( 35 | title = title?.getString(language) ?: "Unknown", 36 | subtitle = subtitle?.getString(language), 37 | list = items.mapNotNull { item -> 38 | item.toEchoMediaItem(single, quality) 39 | }, 40 | more = view_more?.getBrowseParamsData()?.browse_id?.let { id -> 41 | PagedData.Single { 42 | val rows = 43 | api.GenericFeedViewMorePage.getGenericFeedViewMorePage(id).getOrThrow() 44 | rows.mapNotNull { itemLayout -> 45 | itemLayout.toEchoMediaItem(single, quality) 46 | } 47 | } 48 | } 49 | ) 50 | } 51 | 52 | fun YtmMediaItem.toEchoMediaItem( 53 | single: Boolean, 54 | quality: ThumbnailProvider.Quality 55 | ): EchoMediaItem? { 56 | return when (this) { 57 | is YtmSong -> EchoMediaItem.TrackItem(toTrack(quality)) 58 | is YtmPlaylist -> when (type) { 59 | YtmPlaylist.Type.ALBUM -> EchoMediaItem.Lists.AlbumItem(toAlbum(single, quality)) 60 | else -> { 61 | if (id != "VLSE") EchoMediaItem.Lists.PlaylistItem(toPlaylist(quality)) 62 | else null 63 | } 64 | } 65 | 66 | is YtmArtist -> toArtist(quality).let { EchoMediaItem.Profile.ArtistItem(it) } 67 | else -> null 68 | } 69 | } 70 | 71 | fun YtmPlaylist.toPlaylist( 72 | quality: ThumbnailProvider.Quality, related: String? = null 73 | ): Playlist { 74 | val extras = mutableMapOf() 75 | related?.let { extras["relatedId"] = it } 76 | val bool = owner_id?.split(",")?.map { 77 | it.toBoolean() 78 | } ?: listOf(false, false) 79 | return Playlist( 80 | id = id, 81 | title = name ?: "Unknown", 82 | isEditable = bool.getOrNull(1) ?: false, 83 | cover = thumbnail_provider?.getThumbnailUrl(quality)?.toImageHolder(mapOf()), 84 | authors = artists?.map { it.toUser(quality) } ?: emptyList(), 85 | tracks = item_count, 86 | duration = total_duration, 87 | creationDate = year?.toDate(), 88 | description = description, 89 | extras = extras, 90 | ) 91 | } 92 | 93 | fun YtmPlaylist.toAlbum( 94 | single: Boolean = false, 95 | quality: ThumbnailProvider.Quality 96 | ): Album { 97 | val bool = owner_id?.split(",")?.map { 98 | it.toBoolean() 99 | } ?: listOf(false, false) 100 | return Album( 101 | id = id, 102 | title = name ?: "Unknown", 103 | isExplicit = bool.firstOrNull() ?: false, 104 | cover = thumbnail_provider?.getThumbnailUrl(quality)?.toImageHolder(mapOf()), 105 | artists = artists?.map { it.toArtist(quality) } ?: emptyList(), 106 | tracks = item_count ?: if (single) 1 else null, 107 | releaseDate = year?.toDate(), 108 | label = null, 109 | duration = total_duration, 110 | description = description, 111 | ) 112 | } 113 | 114 | fun YtmSong.toTrack( 115 | quality: ThumbnailProvider.Quality, 116 | setId: String? = null 117 | ): Track { 118 | val album = album?.toAlbum(false, quality) 119 | val extras = mutableMapOf() 120 | setId?.let { extras["setId"] = it } 121 | return Track( 122 | id = id, 123 | title = name ?: "Unknown", 124 | artists = artists?.map { it.toArtist(quality) } ?: emptyList(), 125 | cover = thumbnail_provider?.getThumbnailUrl(quality)?.toImageHolder(crop = true) 126 | ?: getCover(id, quality), 127 | album = album, 128 | duration = duration, 129 | plays = null, 130 | releaseDate = album?.releaseDate, 131 | isLiked = is_explicit, 132 | extras = extras, 133 | ) 134 | } 135 | 136 | private fun getCover( 137 | id: String, 138 | quality: ThumbnailProvider.Quality 139 | ): ImageHolder.UrlRequestImageHolder { 140 | return when (quality) { 141 | ThumbnailProvider.Quality.LOW -> "https://img.youtube.com/vi/$id/mqdefault.jpg" 142 | ThumbnailProvider.Quality.HIGH -> "https://img.youtube.com/vi/$id/maxresdefault.jpg" 143 | }.toImageHolder(crop = true) 144 | } 145 | 146 | fun YtmArtist.toArtist( 147 | quality: ThumbnailProvider.Quality, 148 | ): Artist { 149 | return Artist( 150 | id = id, 151 | name = name ?: "Unknown", 152 | cover = thumbnail_provider?.getThumbnailUrl(quality)?.toImageHolder(mapOf()), 153 | description = description, 154 | followers = subscriber_count, 155 | isFollowing = subscribed ?: false, 156 | extras = mutableMapOf().apply { 157 | subscribe_channel_id?.let { put("subId", it) } 158 | } 159 | ) 160 | } 161 | 162 | fun YtmArtist.toUser( 163 | quality: ThumbnailProvider.Quality, 164 | ): User { 165 | return User( 166 | id = id, 167 | name = name ?: "Unknown", 168 | cover = thumbnail_provider?.getThumbnailUrl(quality)?.toImageHolder(mapOf()) 169 | ) 170 | } 171 | 172 | fun User.toArtist(): Artist { 173 | return Artist( 174 | id = id, 175 | name = name, 176 | cover = cover, 177 | extras = extras 178 | ) 179 | } 180 | 181 | 182 | val json = Json { ignoreUnknownKeys = true } 183 | suspend fun HttpResponse.getUsers( 184 | cookie: String, 185 | auth: String 186 | ) = bodyAsText().let { 187 | val trimmed = it.substringAfter(")]}'") 188 | json.decodeFromString(trimmed) 189 | }.getUsers(cookie, auth) 190 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoSearchEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.endpoint.SearchFilter 4 | import dev.toastbits.ytmkt.endpoint.SearchResults 5 | import dev.toastbits.ytmkt.endpoint.SearchType 6 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 7 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiPostBody 8 | import dev.toastbits.ytmkt.impl.youtubei.endpoint.YTMGetSongFeedEndpoint 9 | import dev.toastbits.ytmkt.model.ApiEndpoint 10 | import dev.toastbits.ytmkt.model.external.ItemLayoutType 11 | import dev.toastbits.ytmkt.model.external.mediaitem.MediaItemLayout 12 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtist 13 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmPlaylist 14 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmSong 15 | import dev.toastbits.ytmkt.model.internal.DidYouMeanRenderer 16 | import dev.toastbits.ytmkt.model.internal.ItemSectionRenderer 17 | import dev.toastbits.ytmkt.model.internal.MusicCardShelfRenderer 18 | import dev.toastbits.ytmkt.model.internal.NavigationEndpoint 19 | import dev.toastbits.ytmkt.model.internal.TextRuns 20 | import dev.toastbits.ytmkt.model.internal.YoutubeiShelf 21 | import dev.toastbits.ytmkt.uistrings.YoutubeUiString 22 | import io.ktor.client.call.body 23 | import io.ktor.client.request.request 24 | import io.ktor.client.statement.HttpResponse 25 | import kotlinx.serialization.Serializable 26 | import kotlinx.serialization.json.put 27 | 28 | class EchoSearchEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 29 | suspend fun search( 30 | query: String, 31 | params: String?, 32 | auth: Boolean = true, 33 | nonMusic: Boolean = false 34 | ): Result = runCatching { 35 | val hl: String = api.data_language 36 | val response: HttpResponse = api.client.request { 37 | endpointPath("search", non_music_api = nonMusic) 38 | if (auth) addApiHeadersWithAuthenticated(non_music_api = nonMusic) 39 | else addApiHeadersWithoutAuthentication(non_music_api = nonMusic) 40 | postWithBody( 41 | (if (nonMusic) YoutubeiPostBody.WEB else YoutubeiPostBody.DEFAULT).getPostBody(api) 42 | ) { 43 | put("query", query) 44 | put("params", params) 45 | } 46 | } 47 | 48 | val parsed: YoutubeiSearchResponse = response.body() 49 | 50 | val sectionListRenderers: List = 51 | parsed.contents.getSectionListRenderers() ?: emptyList() 52 | 53 | var correctionSuggestion: String? = null 54 | 55 | val categories: List = 56 | sectionListRenderers.flatMap { renderer -> 57 | renderer.contents.orEmpty().filter { shelf -> 58 | val didYouMeanRenderer: DidYouMeanRenderer? = 59 | shelf.itemSectionRenderer?.contents?.firstOrNull()?.didYouMeanRenderer 60 | 61 | if (didYouMeanRenderer != null) { 62 | correctionSuggestion = didYouMeanRenderer.correctedQuery.first_text 63 | return@filter false 64 | } else { 65 | return@filter true 66 | } 67 | } 68 | } 69 | 70 | val categoryLayouts: MutableList> = mutableListOf() 71 | val chips = 72 | sectionListRenderers.flatMap { it.header?.chipCloudRenderer?.chips ?: emptyList() } 73 | 74 | for ((index, category) in categories.withIndex()) { 75 | val card: MusicCardShelfRenderer? = category.musicCardShelfRenderer 76 | val key: String? = 77 | card?.header?.musicCardShelfHeaderBasicRenderer?.title?.firstTextOrNull() 78 | if (card != null && key != null) { 79 | try { 80 | categoryLayouts.add( 81 | Pair( 82 | MediaItemLayout( 83 | mutableListOf(card.getMediaItem()), 84 | YoutubeUiString.Type.SEARCH_PAGE.createFromKey(key, hl), 85 | null, 86 | type = ItemLayoutType.CARD 87 | ), 88 | null 89 | ) 90 | ) 91 | continue 92 | } catch (e: Exception) { 93 | println("DEBUG: Failed to process MusicCardShelfRenderer: ${e.message}") 94 | } 95 | } 96 | 97 | val itemSectionRenderer: ItemSectionRenderer? = category.itemSectionRenderer 98 | if (itemSectionRenderer != null) { 99 | categoryLayouts.add( 100 | Pair(MediaItemLayout(itemSectionRenderer.getMediaItems(), null, null), null) 101 | ) 102 | continue 103 | } 104 | 105 | val shelf: YTMGetSongFeedEndpoint.MusicShelfRenderer = 106 | category.musicShelfRenderer ?: continue 107 | val items = 108 | shelf.contents?.mapNotNull { it.toMediaItemData(hl, api)?.first }?.toMutableList() 109 | ?: continue 110 | val searchParams = 111 | if (index == 0) null else chips.getOrNull(index - 1)?.chipCloudChipRenderer?.navigationEndpoint?.searchEndpoint?.params 112 | 113 | val title: String? = shelf.title?.firstTextOrNull() 114 | if (title != null) { 115 | categoryLayouts.add(Pair( 116 | MediaItemLayout( 117 | items, 118 | YoutubeUiString.Type.SEARCH_PAGE.createFromKey(title, hl), 119 | null 120 | ), 121 | searchParams?.let { 122 | val item = items.firstOrNull() ?: return@let null 123 | SearchFilter( 124 | when (item) { 125 | is YtmSong -> 126 | if (item.type == YtmSong.Type.VIDEO) SearchType.VIDEO else SearchType.SONG 127 | 128 | is YtmArtist -> 129 | SearchType.ARTIST 130 | 131 | is YtmPlaylist -> 132 | when (item.type) { 133 | YtmPlaylist.Type.ALBUM -> SearchType.ALBUM 134 | else -> SearchType.PLAYLIST 135 | } 136 | 137 | else -> throw NotImplementedError(item::class.toString()) 138 | }, 139 | it 140 | ) 141 | } 142 | )) 143 | } 144 | } 145 | 146 | if (correctionSuggestion == null && query.trim().lowercase() == "recursion") { 147 | correctionSuggestion = query 148 | } 149 | 150 | return@runCatching SearchResults(categoryLayouts, correctionSuggestion) 151 | } 152 | } 153 | 154 | @Serializable 155 | private data class YoutubeiSearchResponse( 156 | val contents: Contents 157 | ) { 158 | @Serializable 159 | data class Contents( 160 | val tabbedSearchResultsRenderer: TabbedSearchResultsRenderer?, 161 | val twoColumnSearchResultsRenderer: TwoColumnSearchResultsRenderer? 162 | ) { 163 | fun getSectionListRenderers(): List? = 164 | tabbedSearchResultsRenderer?.tabs?.mapNotNull { it.tabRenderer.content?.sectionListRenderer } 165 | ?: twoColumnSearchResultsRenderer?.primaryContents?.let { listOf(it.sectionListRenderer) } 166 | } 167 | 168 | @Serializable 169 | data class TabbedSearchResultsRenderer(val tabs: List) { 170 | @Serializable 171 | data class Tab(val tabRenderer: TabRenderer) 172 | 173 | @Serializable 174 | data class TabRenderer(val content: Content?) 175 | } 176 | 177 | @Serializable 178 | data class TwoColumnSearchResultsRenderer(val primaryContents: Content) 179 | 180 | @Serializable 181 | data class Content(val sectionListRenderer: SectionListRenderer) 182 | } 183 | 184 | @Serializable 185 | data class SectionListRenderer( 186 | val contents: List?, 187 | val header: ChipCloudRendererHeader? 188 | ) 189 | 190 | @Serializable 191 | data class ChipCloudRendererHeader(val chipCloudRenderer: ChipCloudRenderer?) 192 | 193 | @Serializable 194 | data class ChipCloudRenderer(val chips: List) 195 | 196 | @Serializable 197 | data class Chip(val chipCloudChipRenderer: ChipCloudChipRenderer) 198 | 199 | @Serializable 200 | data class ChipCloudChipRenderer(val navigationEndpoint: NavigationEndpoint, val text: TextRuns?) 201 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoPlaylistEndpoint.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.brahmkshatriya.echo.common.helpers.Page 4 | import dev.brahmkshatriya.echo.common.helpers.PagedData 5 | import dev.brahmkshatriya.echo.common.models.Track 6 | import dev.brahmkshatriya.echo.extension.YoutubeExtension.Companion.SONGS 7 | import dev.brahmkshatriya.echo.extension.toTrack 8 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 9 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiPostBody 10 | import dev.toastbits.ytmkt.model.ApiEndpoint 11 | import dev.toastbits.ytmkt.model.YtmApi 12 | import dev.toastbits.ytmkt.model.external.ThumbnailProvider 13 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmPlaylist 14 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmPlaylistBuilder 15 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmSong 16 | import dev.toastbits.ytmkt.model.internal.TextRun 17 | import dev.toastbits.ytmkt.radio.RadioContinuation 18 | import io.ktor.client.call.body 19 | import io.ktor.client.request.request 20 | import io.ktor.client.statement.HttpResponse 21 | import kotlinx.serialization.Serializable 22 | import kotlinx.serialization.json.put 23 | 24 | class EchoPlaylistEndpoint(override val api: YoutubeiApi) : ApiEndpoint() { 25 | 26 | private val continuationEndpoint = EchoPlaylistContinuationEndpoint(api) 27 | 28 | private fun formatBrowseId(browseId: String) = 29 | if (!browseId.startsWith("VL") && !browseId.startsWith("MPREb_")) "VL$browseId" 30 | else browseId 31 | 32 | private fun cleanId(playlistId: String) = 33 | if (playlistId.startsWith("VL")) playlistId.substring(2) 34 | else playlistId 35 | 36 | suspend fun loadFromPlaylist( 37 | playlistId: String, 38 | params: String? = null, 39 | quality: ThumbnailProvider.Quality 40 | ): Triple> = run { 41 | 42 | val endpoint = if (!playlistId.startsWith("MPREb_")) 43 | api.client.request { 44 | endpointPath("navigation/resolve_url") 45 | addApiHeadersWithAuthenticated() 46 | postWithBody { 47 | put("url", "https://music.youtube.com/playlist?list=${cleanId(playlistId)}") 48 | } 49 | }.body().endpoint?.browseEndpoint?.takeIf { 50 | val pageType = 51 | it.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType 52 | pageType == "MUSIC_PAGE_TYPE_PLAYLIST" || pageType == "MUSIC_PAGE_TYPE_ALBUM" 53 | } 54 | else null 55 | 56 | val id = endpoint?.browseId ?: formatBrowseId(playlistId) 57 | val param = endpoint?.params ?: params 58 | 59 | val res = api.client.request { 60 | endpointPath("browse") 61 | addApiHeadersWithAuthenticated() 62 | postWithBody(YoutubeiPostBody.BASE.getPostBody(api)) { 63 | put("browseId", id) 64 | if (param != null) { 65 | put("params", param) 66 | } 67 | } 68 | } 69 | val (playlist, relation) = 70 | parsePlaylistResponse(cleanId(id), res, api.data_language, api) 71 | val songs = PagedData.Continuous { token -> 72 | if (token == null) { 73 | val ytmSongs = playlist.items ?: emptyList() 74 | val sets = playlist.item_set_ids!! 75 | Page( 76 | ytmSongs.mapIndexed { index, it -> it.toTrack(quality, sets[index]) }, 77 | playlist.continuation?.token 78 | ) 79 | } else { 80 | val (songs, setIds, cont) = continuationEndpoint.load(token) 81 | val ytmSongs = songs ?: emptyList() 82 | val sets = setIds ?: emptyList() 83 | Page(ytmSongs.mapIndexed { index, it -> it.toTrack(quality, sets[index]) }, cont) 84 | } 85 | } 86 | Triple(playlist, relation, songs) 87 | } 88 | 89 | 90 | @Serializable 91 | data class PlaylistEndpointResponse( 92 | val endpoint: Endpoint? = null 93 | ) { 94 | 95 | @Serializable 96 | data class Endpoint( 97 | val browseEndpoint: BrowseEndpoint? = null 98 | ) 99 | 100 | @Serializable 101 | data class BrowseEndpoint( 102 | val browseId: String? = null, 103 | val params: String? = null, 104 | val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null 105 | ) 106 | 107 | @Serializable 108 | data class BrowseEndpointContextSupportedConfigs( 109 | val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig? = null 110 | ) 111 | 112 | @Serializable 113 | data class BrowseEndpointContextMusicConfig( 114 | val pageType: String? = null 115 | ) 116 | } 117 | 118 | companion object { 119 | private val regex = Regex("(\\d+) $SONGS") 120 | fun List.findSongCount(): Int? { 121 | val count = this.firstOrNull { it.text.contains(SONGS) }?.text ?: return null 122 | val result = regex.find(count)?.groupValues?.get(1) 123 | return result?.toIntOrNull() 124 | } 125 | 126 | private val trackRegex = Regex("(\\d+) track") 127 | fun List.findTrackCount(): Int? { 128 | val count = this.firstOrNull { it.text.contains("track") }?.text ?: return null 129 | val result = trackRegex.find(count)?.groupValues?.get(1) 130 | return result?.toIntOrNull() 131 | } 132 | 133 | suspend fun parsePlaylistResponse( 134 | playlistId: String, 135 | response: HttpResponse, 136 | hl: String, 137 | api: YtmApi 138 | ) = run { 139 | val parsed: YoutubeiBrowseResponse = response.body() 140 | val builder = YtmPlaylistBuilder(playlistId) 141 | val playlistData = parsed.contents?.twoColumnBrowseResultsRenderer?.tabs?.firstOrNull() 142 | ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() 143 | ?.getPlaylistData(hl) 144 | 145 | if (playlistData != null) { 146 | builder.name = playlistData.title 147 | builder.description = playlistData.description 148 | builder.thumbnail_provider = playlistData.thumbnail 149 | builder.artists = playlistData.artists 150 | builder.year = playlistData.year 151 | builder.owner_id = "${playlistData.explicit},${playlistData.isEditable}" 152 | builder.item_count = playlistData.count 153 | builder.total_duration = playlistData.duration 154 | } 155 | 156 | val sectionListRenderer = parsed.contents?.run { 157 | singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer 158 | ?: twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer 159 | } 160 | var continuationToken : String?=null 161 | val items = sectionListRenderer?.contents?.mapNotNull { row -> 162 | continuationToken = row.musicPlaylistShelfRenderer?.contents?.lastOrNull() 163 | ?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token 164 | row.getMediaItemsAndSetIds(hl, api)?.mapNotNull { (item, set) -> 165 | if (item is YtmSong) item to set 166 | else null 167 | } ?: return@mapNotNull null 168 | }?.flatten() ?: emptyList() 169 | 170 | builder.items = items.map { it.first } 171 | builder.item_set_ids = items.map { it.second ?: "Unknown" } 172 | builder.item_count = builder.item_count 173 | builder.continuation = continuationToken?.let { 174 | RadioContinuation(it, RadioContinuation.Type.PLAYLIST) 175 | } 176 | 177 | val continuationItems = 178 | parsed.continuationContents?.musicPlaylistShelfContinuation?.contents?.mapNotNull { 179 | it.toMediaItemData(hl, api) 180 | } 181 | if (continuationItems != null) { 182 | builder.items = continuationItems.map { it.first }.filterIsInstance() 183 | builder.item_set_ids = continuationItems.mapNotNull { it.second } 184 | val cont = 185 | parsed.continuationContents.musicPlaylistShelfContinuation.continuations?.firstOrNull()?.nextContinuationData?.continuation 186 | builder.continuation = 187 | cont?.let { RadioContinuation(it, RadioContinuation.Type.PLAYLIST) } 188 | } 189 | 190 | var relatedId = 191 | sectionListRenderer?.continuations?.firstOrNull()?.nextContinuationData?.continuation 192 | 193 | relatedId = relatedId ?: items.lastOrNull()?.first?.id?.let { "id://$it" } 194 | 195 | builder.build() to relatedId 196 | } 197 | } 198 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/GoogleAccountResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder 4 | import dev.brahmkshatriya.echo.common.models.User 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class GoogleAccountResponse( 9 | val code: String, 10 | val data: Data 11 | ) { 12 | 13 | private fun getAccountList(): List> { 14 | return data.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].accountSectionListRenderer.run { 15 | contents.map { 16 | it.accountItemSectionRenderer.contents.mapNotNull { content -> 17 | content.accountItem 18 | } 19 | }.flatten().map { 20 | header.googleAccountHeaderRenderer?.email?.simpleText to it 21 | } 22 | } 23 | } 24 | 25 | fun getUsers(cookie: String, auth: String): List { 26 | return getAccountList().mapNotNull { 27 | val (email, item) = it 28 | if (item.isDisabled == true) return@mapNotNull null 29 | val cover = 30 | item.accountPhoto?.thumbnails?.firstOrNull()?.url?.toImageHolder() 31 | val signInUrl = 32 | item.serviceEndpoint?.selectActiveIdentityEndpoint?.supportedTokens 33 | ?.find { token -> token.accountSigninToken != null } 34 | ?.accountSigninToken?.signinUrl ?: return@mapNotNull null 35 | val channelId = item.serviceEndpoint.selectActiveIdentityEndpoint.supportedTokens 36 | .find { token -> token.offlineCacheKeyToken != null } 37 | ?.offlineCacheKeyToken?.clientCacheKey 38 | 39 | User( 40 | if (channelId != null) "UC$channelId" else "", 41 | item.accountName.simpleText, 42 | cover, 43 | email, 44 | mapOf("auth" to auth, "cookie" to cookie, "signInUrl" to signInUrl) 45 | ) 46 | } 47 | } 48 | 49 | @Serializable 50 | data class Data( 51 | val actions: List 52 | ) 53 | 54 | @Serializable 55 | data class Action( 56 | val getMultiPageMenuAction: GetMultiPageMenuAction 57 | ) 58 | 59 | @Serializable 60 | data class GetMultiPageMenuAction( 61 | val menu: Menu 62 | ) 63 | 64 | @Serializable 65 | data class Menu( 66 | val multiPageMenuRenderer: MultiPageMenuRenderer 67 | ) 68 | 69 | @Serializable 70 | data class MultiPageMenuRenderer( 71 | val header: MultiPageMenuRendererHeader? = null, 72 | val sections: List
, 73 | val footer: Footer? = null, 74 | val style: String? = null 75 | ) 76 | 77 | @Serializable 78 | data class Footer( 79 | val multiPageMenuSectionRenderer: MultiPageMenuSectionRenderer? = null 80 | ) 81 | 82 | @Serializable 83 | data class MultiPageMenuSectionRenderer( 84 | val items: List? = null 85 | ) 86 | 87 | @Serializable 88 | data class Item( 89 | val compactLinkRenderer: ItemCompactLinkRenderer? = null 90 | ) 91 | 92 | @Serializable 93 | data class ItemCompactLinkRenderer( 94 | val icon: Icon? = null, 95 | val title: Title? = null, 96 | val navigationEndpoint: PurpleNavigationEndpoint? = null, 97 | val style: String? = null 98 | ) 99 | 100 | @Serializable 101 | data class Icon( 102 | val iconType: String? = null 103 | ) 104 | 105 | @Serializable 106 | data class PurpleNavigationEndpoint( 107 | val commandMetadata: CommandMetadata? = null, 108 | val urlEndpoint: URLEndpoint? = null, 109 | val signOutEndpoint: SignOutEndpoint? = null 110 | ) 111 | 112 | @Serializable 113 | data class CommandMetadata( 114 | val webCommandMetadata: WebCommandMetadata? = null 115 | ) 116 | 117 | @Serializable 118 | data class WebCommandMetadata( 119 | val url: String? = null, 120 | val webPageType: String? = null, 121 | val rootVe: Long? = null 122 | ) 123 | 124 | @Serializable 125 | data class SignOutEndpoint( 126 | val hack: Boolean? = null 127 | ) 128 | 129 | @Serializable 130 | data class URLEndpoint( 131 | val url: String? = null 132 | ) 133 | 134 | @Serializable 135 | data class Title( 136 | val simpleText: String 137 | ) 138 | 139 | @Serializable 140 | data class MultiPageMenuRendererHeader( 141 | val simpleMenuHeaderRenderer: SimpleMenuHeaderRenderer? = null 142 | ) 143 | 144 | @Serializable 145 | data class SimpleMenuHeaderRenderer( 146 | val backButton: BackButton? = null, 147 | val title: Title? = null 148 | ) 149 | 150 | @Serializable 151 | data class BackButton( 152 | val buttonRenderer: ButtonRenderer? = null 153 | ) 154 | 155 | @Serializable 156 | data class ButtonRenderer( 157 | val style: String? = null, 158 | val size: String? = null, 159 | val isDisabled: Boolean? = null, 160 | val icon: Icon? = null, 161 | val accessibility: Accessibility? = null, 162 | val accessibilityData: AccessibilityData? = null 163 | ) 164 | 165 | @Serializable 166 | data class Accessibility( 167 | val label: String? = null 168 | ) 169 | 170 | @Serializable 171 | data class AccessibilityData( 172 | val accessibilityData: Accessibility? = null 173 | ) 174 | 175 | @Serializable 176 | data class Section( 177 | val accountSectionListRenderer: AccountSectionListRenderer 178 | ) 179 | 180 | @Serializable 181 | data class AccountSectionListRenderer( 182 | val contents: List, 183 | val header: AccountSectionListRendererHeader 184 | ) 185 | 186 | @Serializable 187 | data class AccountSectionListRendererContent( 188 | val accountItemSectionRenderer: AccountItemSectionRenderer 189 | ) 190 | 191 | @Serializable 192 | data class AccountItemSectionRenderer( 193 | val contents: List 194 | ) 195 | 196 | @Serializable 197 | data class AccountItemSectionRendererContent( 198 | val accountItem: AccountItem? = null, 199 | val compactLinkRenderer: ContentCompactLinkRenderer? = null 200 | ) 201 | 202 | @Serializable 203 | data class AccountItem( 204 | val accountName: Title, 205 | val accountPhoto: AccountPhoto? = null, 206 | val isSelected: Boolean? = null, 207 | val isDisabled: Boolean? = null, 208 | val hasChannel: Boolean? = null, 209 | val serviceEndpoint: ServiceEndpoint? = null, 210 | val accountByline: Title? = null, 211 | val unlimitedStatus: List? = null, 212 | val channelHandle: Title? = null 213 | ) 214 | 215 | @Serializable 216 | data class AccountPhoto( 217 | val thumbnails: List<Thumbnail>? = null 218 | ) 219 | 220 | @Serializable 221 | data class Thumbnail( 222 | val url: String? = null, 223 | val width: Long? = null, 224 | val height: Long? = null 225 | ) 226 | 227 | @Serializable 228 | data class ServiceEndpoint( 229 | val selectActiveIdentityEndpoint: SelectActiveIdentityEndpoint? = null 230 | ) 231 | 232 | @Serializable 233 | data class SelectActiveIdentityEndpoint( 234 | val supportedTokens: List<SupportedToken>? = null, 235 | val nextNavigationEndpoint: NextNavigationEndpoint? = null 236 | ) 237 | 238 | @Serializable 239 | data class NextNavigationEndpoint( 240 | val commandMetadata: CommandMetadata? = null, 241 | val urlEndpoint: URLEndpoint? = null 242 | ) 243 | 244 | @Serializable 245 | data class SupportedToken( 246 | val accountStateToken: AccountStateToken? = null, 247 | val offlineCacheKeyToken: OfflineCacheKeyToken? = null, 248 | val accountSigninToken: AccountSigninToken? = null, 249 | val datasyncIdToken: DatasyncIdToken? = null 250 | ) 251 | 252 | @Serializable 253 | data class AccountSigninToken( 254 | val signinUrl: String? = null 255 | ) 256 | 257 | @Serializable 258 | data class AccountStateToken( 259 | val hasChannel: Boolean? = null, 260 | val isMerged: Boolean? = null, 261 | val obfuscatedGaiaId: String? = null 262 | ) 263 | 264 | @Serializable 265 | data class DatasyncIdToken( 266 | val datasyncIdToken: String? = null 267 | ) 268 | 269 | @Serializable 270 | data class OfflineCacheKeyToken( 271 | val clientCacheKey: String? = null 272 | ) 273 | 274 | @Serializable 275 | data class ContentCompactLinkRenderer( 276 | val title: Title? = null, 277 | val navigationEndpoint: FluffyNavigationEndpoint? = null 278 | ) 279 | 280 | @Serializable 281 | data class FluffyNavigationEndpoint( 282 | val commandMetadata: CommandMetadata? = null, 283 | val signalNavigationEndpoint: SignalNavigationEndpoint? = null 284 | ) 285 | 286 | @Serializable 287 | data class SignalNavigationEndpoint( 288 | val signal: String? = null 289 | ) 290 | 291 | @Serializable 292 | data class AccountSectionListRendererHeader( 293 | val googleAccountHeaderRenderer: GoogleAccountHeaderRenderer? = null 294 | ) 295 | 296 | @Serializable 297 | data class GoogleAccountHeaderRenderer( 298 | val name: Title? = null, 299 | val email: Title? = null 300 | ) 301 | } -------------------------------------------------------------------------------- /ext/src/test/java/dev/brahmkshatriya/echo/extension/ExtensionUnitTest.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension 2 | 3 | import dev.brahmkshatriya.echo.common.clients.AlbumClient 4 | import dev.brahmkshatriya.echo.common.clients.ArtistClient 5 | import dev.brahmkshatriya.echo.common.clients.ExtensionClient 6 | import dev.brahmkshatriya.echo.common.clients.HomeFeedClient 7 | import dev.brahmkshatriya.echo.common.clients.LoginClient 8 | import dev.brahmkshatriya.echo.common.clients.LyricsClient 9 | import dev.brahmkshatriya.echo.common.clients.PlaylistClient 10 | import dev.brahmkshatriya.echo.common.clients.RadioClient 11 | import dev.brahmkshatriya.echo.common.clients.SearchFeedClient 12 | import dev.brahmkshatriya.echo.common.clients.TrackClient 13 | import dev.brahmkshatriya.echo.common.models.Album 14 | import dev.brahmkshatriya.echo.common.models.Artist 15 | import dev.brahmkshatriya.echo.common.models.EchoMediaItem 16 | import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem 17 | import dev.brahmkshatriya.echo.common.models.Lyrics 18 | import dev.brahmkshatriya.echo.common.models.Playlist 19 | import dev.brahmkshatriya.echo.common.models.Shelf 20 | import dev.brahmkshatriya.echo.common.models.Track 21 | import kotlinx.coroutines.CoroutineScope 22 | import kotlinx.coroutines.DelicateCoroutinesApi 23 | import kotlinx.coroutines.Dispatchers 24 | import kotlinx.coroutines.ExperimentalCoroutinesApi 25 | import kotlinx.coroutines.newSingleThreadContext 26 | import kotlinx.coroutines.runBlocking 27 | import kotlinx.coroutines.test.resetMain 28 | import kotlinx.coroutines.test.setMain 29 | import org.junit.After 30 | import org.junit.Before 31 | import org.junit.Test 32 | import kotlin.system.measureTimeMillis 33 | 34 | @OptIn(DelicateCoroutinesApi::class) 35 | @ExperimentalCoroutinesApi 36 | class ExtensionUnitTest { 37 | private val extension: ExtensionClient = YoutubeExtension() 38 | private val searchQuery = "Skrillex" 39 | 40 | private val mainThreadSurrogate = newSingleThreadContext("UI thread") 41 | 42 | @Before 43 | fun setUp() { 44 | Dispatchers.setMain(mainThreadSurrogate) 45 | extension.setSettings(MockedSettings()) 46 | runBlocking { 47 | extension.onExtensionSelected() 48 | (extension as? LoginClient)?.onSetLoginUser(null) 49 | } 50 | } 51 | 52 | @After 53 | fun tearDown() { 54 | Dispatchers.resetMain() // reset the main dispatcher to the original Main dispatcher 55 | mainThreadSurrogate.close() 56 | } 57 | 58 | private fun testIn(title: String, block: suspend CoroutineScope.() -> Unit) = runBlocking { 59 | println("\n-- $title --") 60 | block.invoke(this) 61 | println("\n") 62 | } 63 | 64 | @Test 65 | fun testHomeFeed() = testIn("Testing Home Feed") { 66 | if (extension !is HomeFeedClient) error("HomeFeedClient is not implemented") 67 | val feed = extension.getHomeFeed(null).loadFirst() 68 | feed.forEach { 69 | println(it) 70 | } 71 | } 72 | 73 | @Test 74 | fun testHomeFeedWithTab() = testIn("Testing Home Feed with Tab") { 75 | if (extension !is HomeFeedClient) error("HomeFeedClient is not implemented") 76 | val tab = extension.getHomeTabs().firstOrNull() 77 | val feed = extension.getHomeFeed(tab).loadFirst() 78 | feed.forEach { 79 | println(it) 80 | } 81 | } 82 | 83 | @Test 84 | fun testNullQuickSearch() = testIn("Testing Null Quick Search") { 85 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 86 | val search = extension.quickSearch("") 87 | search.forEach { 88 | println(it) 89 | } 90 | } 91 | 92 | @Test 93 | fun testQuickSearch() = testIn("Testing Quick Search") { 94 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 95 | val search = extension.quickSearch(searchQuery) 96 | search.forEach { 97 | println(it) 98 | } 99 | } 100 | 101 | @Test 102 | fun testNullSearch() = testIn("Testing Null Search") { 103 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 104 | val search = extension.searchFeed("", null).loadFirst() 105 | search.forEach { 106 | println(it) 107 | } 108 | } 109 | 110 | @Test 111 | fun testSearch() = testIn("Testing Search") { 112 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 113 | println("Tabs") 114 | extension.searchTabs(searchQuery).forEach { 115 | println(it.title) 116 | } 117 | println("Search Results") 118 | val search = extension.searchFeed(searchQuery, null).loadFirst() 119 | search.forEach { 120 | println(it) 121 | } 122 | } 123 | 124 | @Test 125 | fun testSearchWithTab() = testIn("Testing Search with Tab") { 126 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 127 | val tab = extension.searchTabs(searchQuery).firstOrNull() 128 | val search = extension.searchFeed(searchQuery, tab).loadFirst() 129 | search.forEach { 130 | println(it) 131 | } 132 | } 133 | 134 | 135 | private suspend fun searchTrack(q: String? = null): Track { 136 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 137 | val query = q ?: searchQuery 138 | println("Searching : $query") 139 | val items = extension.searchFeed(query, null).loadFirst() 140 | val track = items.firstNotNullOfOrNull { 141 | val item = when (it) { 142 | is Shelf.Item -> it.media 143 | is Shelf.Lists.Items -> it.list.firstOrNull() 144 | is Shelf.Lists.Tracks -> it.list.firstOrNull()?.toMediaItem() 145 | else -> null 146 | } 147 | (item as? EchoMediaItem.TrackItem)?.track 148 | } 149 | return track ?: error("Track not found, try a different search query") 150 | } 151 | 152 | @Test 153 | fun testTrackGet() = testIn("Testing Track Get") { 154 | if (extension !is TrackClient) error("TrackClient is not implemented") 155 | val search = Track("5XR7naZ_zZA", "") 156 | measureTimeMillis { 157 | val track = extension.loadTrack(search) 158 | println(track.isLiked) 159 | }.also { println("time : $it") } 160 | } 161 | 162 | @Test 163 | fun testTrackStream() = testIn("Testing Track Stream") { 164 | if (extension !is TrackClient) error("TrackClient is not implemented") 165 | val search = Track("qeFt3fdsydA", "") 166 | measureTimeMillis { 167 | val track = extension.loadTrack(search) 168 | println(track.servers) 169 | val streamable = track.servers.firstOrNull() 170 | ?: error("Track is not streamable") 171 | val stream = extension.loadStreamableMedia(streamable, false) 172 | println(stream) 173 | }.also { println("time : $it") } 174 | } 175 | 176 | @Test 177 | fun testTrackRadio() = testIn("Testing Track Radio") { 178 | if (extension !is TrackClient) error("TrackClient is not implemented") 179 | if (extension !is RadioClient) error("RadioClient is not implemented") 180 | val track = extension.loadTrack(searchTrack()) 181 | val radio = extension.radio(track, null) 182 | val radioTracks = extension.loadTracks(radio).loadFirst() 183 | radioTracks.forEach { 184 | println(it) 185 | } 186 | } 187 | 188 | @Test 189 | fun testTrackMediaItems() = testIn("Testing Track Media Items") { 190 | if (extension !is TrackClient) error("TrackClient is not implemented") 191 | val track = extension.loadTrack(Track("iDkSRTBDxJY", "")) 192 | val mediaItems = extension.getShelves(track).loadFirst() 193 | mediaItems.forEach { 194 | println(it) 195 | } 196 | } 197 | 198 | @Test 199 | fun testAlbumGet() = testIn("Testing Album Get") { 200 | if (extension !is TrackClient) error("TrackClient is not implemented") 201 | // val small = extension.loadTrack(searchTrack()).album ?: error("Track has no album") 202 | val small = Album("OLAK5uy_mxh0nd0ZvSxiDVWflNGUSWxGz0ju1OA-E", "") 203 | if (extension !is AlbumClient) error("AlbumClient is not implemented") 204 | val album = extension.loadAlbum(small) 205 | println(album) 206 | // val mediaItems = extension.getMediaItems(album).loadFirst() 207 | // mediaItems.forEach { 208 | // println(it) 209 | // } 210 | } 211 | 212 | @Test 213 | fun testPlaylistMediaItems() = testIn("Testing Playlist Media Items") { 214 | if (extension !is PlaylistClient) error("PlaylistClient is not implemented") 215 | val playlist = extension.loadPlaylist( 216 | Playlist("RDCLAK5uy_kPazyVLjdDuNJ_OxrjeXIyMYeKsz7vVAU", "", false) 217 | ) 218 | println(playlist) 219 | val tracks = extension.loadTracks(playlist).loadAll() 220 | println(tracks.size) 221 | // val mediaItems = extension.getShelves(playlist).loadFirst() 222 | // mediaItems.forEach { 223 | // println(it) 224 | // } 225 | } 226 | 227 | @Test 228 | fun testArtistMediaItems() = testIn("Testing Artist Media Items") { 229 | val small = Artist("UCySqAU8DY0BnB2j5uYdCbLA", "") 230 | if (extension !is ArtistClient) error("ArtistClient is not implemented") 231 | val artist = extension.loadArtist(small) 232 | println(artist) 233 | val mediaItems = extension.getShelves(artist).loadFirst() 234 | mediaItems.forEach { 235 | it as Shelf.Lists.Items 236 | println("${it.title} : ${it.subtitle}") 237 | println("${it.list.size} : ${it.more}") 238 | it.list.forEach { item -> 239 | println("${item.title} : ${item.subtitleWithE}") 240 | } 241 | if (it.more != null) { 242 | println("Loading More") 243 | it.more?.loadFirst()?.forEach { item -> 244 | println("${item.title} : ${item.subtitleWithE}") 245 | } 246 | } 247 | } 248 | } 249 | 250 | @Test 251 | fun testLyrics() = testIn("Lyrics") { 252 | if (extension !is TrackClient) error("TrackClient is not implemented") 253 | val small = Track("iDkSRTBDxJY", "") 254 | val track = extension.loadTrack(small) 255 | println(track) 256 | if (extension !is LyricsClient) error("LyricsClient is not implemented") 257 | val lyricsItems = extension.searchTrackLyrics("", track).loadFirst() 258 | val lyricsItem = lyricsItems.firstOrNull() 259 | if (lyricsItem == null) { 260 | println("Lyrics not found") 261 | return@testIn 262 | } 263 | val loaded = extension.loadLyrics(lyricsItem) 264 | (loaded.lyrics as Lyrics.Timed).list.forEach { 265 | println(it) 266 | } 267 | } 268 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/EchoSongEndPoint.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused", "LocalVariableName") 2 | 3 | package dev.brahmkshatriya.echo.extension.endpoints 4 | 5 | import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder 6 | import dev.brahmkshatriya.echo.common.models.Track 7 | import dev.brahmkshatriya.echo.extension.toAlbum 8 | import dev.brahmkshatriya.echo.extension.toArtist 9 | import dev.toastbits.ytmkt.impl.youtubei.YoutubeiApi 10 | import dev.toastbits.ytmkt.model.ApiEndpoint 11 | import dev.toastbits.ytmkt.model.external.Thumbnail 12 | import dev.toastbits.ytmkt.model.external.ThumbnailProvider 13 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtist 14 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmMediaItem 15 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmPlaylist 16 | import dev.toastbits.ytmkt.model.internal.BrowseEndpoint 17 | import dev.toastbits.ytmkt.model.internal.MusicResponsiveListItemRenderer 18 | import dev.toastbits.ytmkt.model.internal.MusicThumbnailRenderer 19 | import dev.toastbits.ytmkt.model.internal.NavigationEndpoint 20 | import dev.toastbits.ytmkt.model.internal.TextRun 21 | import dev.toastbits.ytmkt.model.internal.TextRuns 22 | import dev.toastbits.ytmkt.model.internal.WatchEndpoint 23 | import dev.toastbits.ytmkt.uistrings.parseYoutubeDurationString 24 | import io.ktor.client.call.body 25 | import io.ktor.client.request.request 26 | import io.ktor.client.statement.HttpResponse 27 | import kotlinx.serialization.Serializable 28 | import kotlinx.serialization.json.put 29 | 30 | open class EchoSongEndPoint(override val api: YoutubeiApi) : ApiEndpoint() { 31 | suspend fun loadSong( 32 | @Suppress("LocalVariableName") song_id: String 33 | ): Result<Track> = runCatching { 34 | val nextResponse: HttpResponse = api.client.request { 35 | endpointPath("next") 36 | addApiHeadersWithAuthenticated() 37 | postWithBody { 38 | put("enablePersistentPlaylistPanel", true) 39 | put("isAudioOnly", true) 40 | put("videoId", song_id) 41 | } 42 | } 43 | return@runCatching parseSongResponse(song_id, nextResponse, api).getOrThrow() 44 | } 45 | 46 | private suspend fun parseSongResponse( 47 | songId: String, 48 | response: HttpResponse, 49 | api: YoutubeiApi 50 | ) = runCatching { 51 | val responseData: YoutubeiNextResponse = response.body() 52 | val tabs: List<YoutubeiNextResponse.Tab> = 53 | responseData 54 | .contents 55 | .singleColumnMusicWatchNextResultsRenderer 56 | .tabbedRenderer 57 | .watchNextTabbedResultsRenderer 58 | .tabs 59 | 60 | val lyricsBrowseId: String? = 61 | tabs.getOrNull(1)?.tabRenderer?.endpoint?.browseEndpoint?.browseId 62 | val relatedBrowseId: String? = 63 | tabs.getOrNull(2)?.tabRenderer?.endpoint?.browseEndpoint?.browseId 64 | 65 | val video: YoutubeiNextResponse.PlaylistPanelVideoRenderer = 66 | tabs[0].tabRenderer.content!!.musicQueueRenderer.content!!.playlistPanelRenderer.contents.first().playlistPanelVideoRenderer!! 67 | 68 | val title: String = video.title.first_text 69 | val liked = 70 | responseData.playerOverlays?.playerOverlayRenderer?.actions?.firstOrNull()?.likeButtonRenderer?.likeStatus == "LIKE" 71 | 72 | val artists: List<YtmArtist> = video.getArtists().getOrThrow() ?: emptyList() 73 | val album = video.getAlbum() 74 | val duration = parseYoutubeDurationString(video.lengthText.first_text, api.data_language) 75 | 76 | val cover = ThumbnailProvider.fromThumbnails(video.thumbnail.thumbnails) 77 | ?.getThumbnailUrl(ThumbnailProvider.Quality.HIGH)?.toImageHolder() 78 | return@runCatching Track( 79 | id = songId, 80 | title = title, 81 | cover = cover, 82 | artists = artists.map { it.toArtist(ThumbnailProvider.Quality.HIGH) }, 83 | album = album?.toAlbum(false, ThumbnailProvider.Quality.HIGH), 84 | duration = duration, 85 | isLiked = liked, 86 | extras = mutableMapOf<String, String>().apply { 87 | relatedBrowseId?.let { put("relatedId", it) } 88 | lyricsBrowseId?.let { put("lyricsId", it) } 89 | }, 90 | ) 91 | } 92 | } 93 | 94 | @Serializable 95 | private data class PlayerData( 96 | val videoDetails: VideoDetails?, 97 | ) { 98 | @Serializable 99 | class VideoDetails( 100 | val title: String, 101 | val channelId: String, 102 | ) 103 | } 104 | 105 | 106 | @Serializable 107 | data class YoutubeiNextResponse( 108 | val contents: Contents, 109 | val playerOverlays: PlayerOverlays? = null 110 | ) { 111 | 112 | @Serializable 113 | data class PlayerOverlays( 114 | val playerOverlayRenderer: PlayerOverlayRenderer? = null 115 | ) 116 | 117 | @Serializable 118 | data class PlayerOverlayRenderer( 119 | val actions: List<PlayerOverlayRendererAction>? = null, 120 | val browserMediaSession: BrowserMediaSession? = null 121 | ) 122 | 123 | @Serializable 124 | data class PlayerOverlayRendererAction( 125 | val likeButtonRenderer: LikeButtonRenderer? = null 126 | ) 127 | 128 | @Serializable 129 | data class LikeButtonRenderer( 130 | val target: Target? = null, 131 | val likeStatus: String? = null, 132 | val trackingParams: String? = null, 133 | val likesAllowed: Boolean? = null, 134 | val serviceEndpoints: List<ServiceEndpoint>? = null 135 | ) 136 | 137 | @Serializable 138 | data class ServiceEndpoint( 139 | val clickTrackingParams: String? = null, 140 | val likeEndpoint: LikeEndpoint? = null 141 | ) 142 | 143 | @Serializable 144 | data class LikeEndpoint( 145 | val status: String? = null, 146 | val target: Target? = null, 147 | val actions: List<LikeEndpointAction>? = null, 148 | val likeParams: String? = null, 149 | val dislikeParams: String? = null, 150 | val removeLikeParams: String? = null 151 | ) 152 | 153 | @Serializable 154 | data class LikeEndpointAction( 155 | val clickTrackingParams: String? = null, 156 | val musicLibraryStatusUpdateCommand: MusicLibraryStatusUpdateCommand? = null 157 | ) 158 | 159 | @Serializable 160 | data class MusicLibraryStatusUpdateCommand( 161 | val libraryStatus: String? = null, 162 | val addToLibraryFeedbackToken: String? = null 163 | ) 164 | 165 | @Serializable 166 | data class Target( 167 | val videoId: String? = null 168 | ) 169 | 170 | @Serializable 171 | data class BrowserMediaSession( 172 | val browserMediaSessionRenderer: BrowserMediaSessionRenderer? = null 173 | ) 174 | 175 | @Serializable 176 | data class BrowserMediaSessionRenderer( 177 | val album: Album? = null, 178 | val thumbnailDetails: ThumbnailDetails? = null 179 | ) 180 | 181 | @Serializable 182 | data class Album( 183 | val runs: List<Run>? = null 184 | ) 185 | 186 | @Serializable 187 | data class Run( 188 | val text: String? = null 189 | ) 190 | 191 | @Serializable 192 | data class ThumbnailDetails( 193 | val thumbnails: List<Thumbnail>? = null 194 | ) 195 | 196 | @Serializable 197 | class Contents(val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer) 198 | 199 | @Serializable 200 | class SingleColumnMusicWatchNextResultsRenderer(val tabbedRenderer: TabbedRenderer) 201 | 202 | @Serializable 203 | class TabbedRenderer(val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer) 204 | 205 | @Serializable 206 | class WatchNextTabbedResultsRenderer(val tabs: List<Tab>) 207 | 208 | @Serializable 209 | class Tab(val tabRenderer: TabRenderer) 210 | 211 | @Serializable 212 | class TabRenderer(val content: Content?, val endpoint: TabRendererEndpoint?) 213 | 214 | @Serializable 215 | class TabRendererEndpoint(val browseEndpoint: BrowseEndpoint) 216 | 217 | @Serializable 218 | class Content(val musicQueueRenderer: MusicQueueRenderer) 219 | 220 | @Serializable 221 | class MusicQueueRenderer( 222 | val content: MusicQueueRendererContent?, 223 | val subHeaderChipCloud: SubHeaderChipCloud? 224 | ) 225 | 226 | @Serializable 227 | class SubHeaderChipCloud(val chipCloudRenderer: ChipCloudRenderer) 228 | 229 | @Serializable 230 | class ChipCloudRenderer(val chips: List<Chip>) 231 | 232 | @Serializable 233 | class Chip(private val chipCloudChipRenderer: ChipCloudChipRenderer) { 234 | fun getPlaylistId(): String? = 235 | chipCloudChipRenderer.navigationEndpoint.queueUpdateCommand.fetchContentsCommand.watchEndpoint.playlistId 236 | } 237 | 238 | @Serializable 239 | class ChipCloudChipRenderer(val navigationEndpoint: ChipNavigationEndpoint) 240 | 241 | @Serializable 242 | class ChipNavigationEndpoint(val queueUpdateCommand: QueueUpdateCommand) 243 | 244 | @Serializable 245 | class QueueUpdateCommand(val fetchContentsCommand: FetchContentsCommand) 246 | 247 | @Serializable 248 | class FetchContentsCommand(val watchEndpoint: WatchEndpoint) 249 | 250 | @Serializable 251 | class MusicQueueRendererContent(val playlistPanelRenderer: PlaylistPanelRenderer) 252 | 253 | @Serializable 254 | class PlaylistPanelRenderer(val contents: List<ResponseRadioItem>) 255 | 256 | @Serializable 257 | data class ResponseRadioItem( 258 | val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?, 259 | val playlistPanelVideoWrapperRenderer: PlaylistPanelVideoWrapperRenderer? 260 | ) { 261 | private fun getRenderer(): PlaylistPanelVideoRenderer { 262 | if (playlistPanelVideoRenderer != null) { 263 | return playlistPanelVideoRenderer 264 | } 265 | 266 | if (playlistPanelVideoWrapperRenderer == null) { 267 | throw NotImplementedError("Unimplemented renderer object in ResponseRadioItem") 268 | } 269 | 270 | return playlistPanelVideoWrapperRenderer.primaryRenderer.getRenderer() 271 | } 272 | } 273 | 274 | @Serializable 275 | class PlaylistPanelVideoWrapperRenderer( 276 | val primaryRenderer: ResponseRadioItem 277 | ) 278 | 279 | @Serializable 280 | class PlaylistPanelVideoRenderer( 281 | val videoId: String, 282 | val title: TextRuns, 283 | private val longBylineText: TextRuns, 284 | val lengthText: TextRuns, 285 | val menu: Menu, 286 | val thumbnail: MusicThumbnailRenderer.RendererThumbnail, 287 | val badges: List<MusicResponsiveListItemRenderer.Badge>? 288 | ) { 289 | fun getArtists(): Result<List<YtmArtist>?> = runCatching { 290 | // Get artist IDs directly 291 | val artists: List<YtmArtist> = (longBylineText.runs.orEmpty() + title.runs.orEmpty()) 292 | .mapNotNull { run -> 293 | val browse_id: String = run.navigationEndpoint?.browseEndpoint?.browseId 294 | ?: return@mapNotNull null 295 | 296 | val page_type = run.browse_endpoint_type?.let { type -> 297 | YtmMediaItem.Type.fromBrowseEndpointType(type) 298 | } 299 | if (page_type != YtmMediaItem.Type.ARTIST) { 300 | return@mapNotNull null 301 | } 302 | 303 | return@mapNotNull YtmArtist( 304 | id = browse_id, 305 | name = run.text 306 | ) 307 | } 308 | 309 | if (artists.isNotEmpty()) { 310 | return@runCatching artists 311 | } 312 | 313 | val menu_artist: String? = 314 | menu.menuRenderer.getArtist()?.menuNavigationItemRenderer?.navigationEndpoint?.browseEndpoint?.browseId 315 | if (menu_artist != null) { 316 | val artist_title: TextRun? = 317 | longBylineText.runs?.firstOrNull { it.navigationEndpoint == null } 318 | if (artist_title != null) { 319 | return@runCatching listOf( 320 | YtmArtist( 321 | id = menu_artist, 322 | name = artist_title.text 323 | ) 324 | ) 325 | } 326 | } 327 | 328 | return@runCatching null 329 | } 330 | 331 | fun getAlbum(): YtmPlaylist? { 332 | for (run in longBylineText.runs.orEmpty()) { 333 | if (run.navigationEndpoint?.browseEndpoint?.getPageType() != "MUSIC_PAGE_TYPE_ALBUM") { 334 | continue 335 | } 336 | 337 | val playlist_id: String = 338 | run.navigationEndpoint?.browseEndpoint?.browseId ?: continue 339 | return YtmPlaylist( 340 | id = playlist_id, 341 | name = run.text, 342 | year = longBylineText.runs?.find { it.text.length == 4 }?.text?.toIntOrNull(), 343 | ) 344 | } 345 | 346 | return null 347 | } 348 | } 349 | 350 | @Serializable 351 | data class Menu(val menuRenderer: MenuRenderer) 352 | 353 | @Serializable 354 | data class MenuRenderer(val items: List<MenuItem>) { 355 | fun getArtist(): MenuItem? = 356 | items.firstOrNull { 357 | it.menuNavigationItemRenderer?.icon?.iconType == "ARTIST" 358 | } 359 | } 360 | 361 | @Serializable 362 | data class MenuItem(val menuNavigationItemRenderer: MenuNavigationItemRenderer?) 363 | 364 | @Serializable 365 | data class MenuNavigationItemRenderer( 366 | val icon: MenuIcon, 367 | val navigationEndpoint: NavigationEndpoint 368 | ) 369 | 370 | @Serializable 371 | data class MenuIcon(val iconType: String) 372 | } 373 | 374 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/MusicShelf.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.toastbits.ytmkt.itemcache.MediaItemCache 4 | import dev.toastbits.ytmkt.model.YtmApi 5 | import dev.toastbits.ytmkt.model.external.Thumbnail 6 | import dev.toastbits.ytmkt.model.external.ThumbnailProvider 7 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtist 8 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmMediaItem 9 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmPlaylist 10 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmSong 11 | import dev.toastbits.ytmkt.model.internal.Header 12 | import dev.toastbits.ytmkt.model.internal.HeaderRenderer 13 | import dev.toastbits.ytmkt.model.internal.MusicResponsiveListItemRenderer 14 | import dev.toastbits.ytmkt.model.internal.NavigationEndpoint 15 | import dev.toastbits.ytmkt.model.internal.TextRuns 16 | import dev.toastbits.ytmkt.model.internal.ThumbnailRenderer 17 | import dev.toastbits.ytmkt.model.internal.YoutubeiHeader 18 | import dev.toastbits.ytmkt.model.internal.YoutubeiHeaderContainer 19 | import dev.toastbits.ytmkt.radio.YoutubeiNextResponse 20 | import dev.toastbits.ytmkt.uistrings.parseYoutubeDurationString 21 | import kotlinx.serialization.Serializable 22 | 23 | @Serializable 24 | data class MusicTwoRowItemRenderer( 25 | val navigationEndpoint: NavigationEndpoint, 26 | val title: TextRuns, 27 | val subtitle: TextRuns?, 28 | val thumbnailRenderer: ThumbnailRenderer, 29 | val menu: Menu?, 30 | val subtitleBadges: List<MusicResponsiveListItemRenderer.Badge>? 31 | ) { 32 | fun toYtmMediaItem(api: YtmApi): YtmMediaItem? { 33 | fun getArtists( 34 | hostItem: YtmMediaItem, 35 | api: YtmApi 36 | ): List<YtmArtist>? { 37 | val artists: List<YtmArtist>? = subtitle?.runs?.mapNotNull { run -> 38 | val browseEndpoint: dev.toastbits.ytmkt.model.internal.BrowseEndpoint? = 39 | run.navigationEndpoint?.browseEndpoint 40 | if (browseEndpoint?.browseId == null || browseEndpoint.getMediaItemType() != YtmMediaItem.Type.ARTIST) { 41 | return@mapNotNull null 42 | } 43 | 44 | return@mapNotNull YtmArtist( 45 | browseEndpoint.browseId!!, 46 | name = run.text 47 | ) 48 | } 49 | 50 | if (!artists.isNullOrEmpty()) { 51 | return artists 52 | } 53 | 54 | if (hostItem is YtmSong) { 55 | val songType: YtmSong.Type? = api.item_cache.getSong( 56 | hostItem.id, 57 | setOf(MediaItemCache.SongKey.TYPE) 58 | )?.type 59 | 60 | val index: Int = if (songType == YtmSong.Type.VIDEO) 0 else 1 61 | subtitle?.runs?.getOrNull(index)?.also { 62 | return listOf( 63 | YtmArtist(YtmArtist.getForItemId(hostItem)).copy( 64 | name = it.text 65 | ) 66 | ) 67 | } 68 | } 69 | 70 | return null 71 | } 72 | 73 | // Video 74 | val watchEndpoint = navigationEndpoint.watchEndpoint 75 | val playlistEndpoint = navigationEndpoint.watchPlaylistEndpoint 76 | return if (watchEndpoint?.videoId != null) { 77 | val album: YtmPlaylist? = menu?.menuRenderer?.items?.find { 78 | it.menuNavigationItemRenderer?.navigationEndpoint?.browseEndpoint?.getMediaItemType() == YtmMediaItem.Type.PLAYLIST 79 | }?.menuNavigationItemRenderer?.navigationEndpoint?.browseEndpoint?.browseId?.let { 80 | YtmPlaylist(YtmPlaylist.cleanId(it)) 81 | } 82 | val thumbnail = 83 | thumbnailRenderer.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull() 84 | val songId: String = YtmSong.cleanId(watchEndpoint.videoId!!) 85 | YtmSong( 86 | id = songId, 87 | type = 88 | if (thumbnail?.height == thumbnail?.width) YtmSong.Type.SONG 89 | else YtmSong.Type.VIDEO, 90 | name = this.title.first_text, 91 | thumbnail_provider = thumbnailRenderer.toThumbnailProvider(), 92 | artists = getArtists(YtmSong(songId), api), 93 | is_explicit = menu?.menuRenderer?.title?.musicMenuTitleRenderer?.endButtons?.firstOrNull()?.likeButtonRenderer?.likeStatus == "LIKE", 94 | album = album 95 | ) 96 | } else if (playlistEndpoint != null) { 97 | YtmPlaylist( 98 | id = YtmPlaylist.cleanId(playlistEndpoint.playlistId), 99 | type = YtmPlaylist.Type.RADIO, 100 | name = title.first_text, 101 | thumbnail_provider = thumbnailRenderer.toThumbnailProvider() 102 | ) 103 | } else { 104 | val endpoint = navigationEndpoint.browseEndpoint 105 | // Playlist or artist 106 | val browseId: String = endpoint?.browseId ?: return null 107 | val pageType: String = endpoint.getPageType() ?: return null 108 | 109 | val title: String = title.first_text 110 | val thumbnailProvider = thumbnailRenderer.toThumbnailProvider() 111 | 112 | when (YtmMediaItem.Type.fromBrowseEndpointType(pageType)) { 113 | YtmMediaItem.Type.SONG -> { 114 | val songId: String = YtmSong.cleanId(browseId) 115 | YtmSong( 116 | songId, 117 | name = title, 118 | thumbnail_provider = thumbnailProvider, 119 | artists = getArtists(YtmSong(songId), api) 120 | ) 121 | } 122 | 123 | YtmMediaItem.Type.ARTIST -> YtmArtist( 124 | browseId, 125 | name = title, 126 | thumbnail_provider = thumbnailProvider 127 | ) 128 | 129 | YtmMediaItem.Type.PLAYLIST -> { 130 | val playlistId: String = YtmPlaylist.cleanId(browseId) 131 | YtmPlaylist( 132 | playlistId, 133 | type = YtmPlaylist.Type.fromBrowseEndpointType(pageType), 134 | artists = getArtists(YtmPlaylist(playlistId), api), 135 | name = title, 136 | thumbnail_provider = thumbnailProvider, 137 | year = subtitle?.runs?.find { it.text.length == 4 }?.text?.toIntOrNull() 138 | ) 139 | } 140 | 141 | null -> null 142 | } 143 | } 144 | } 145 | } 146 | 147 | val timeRegex = Regex("""\d{1,2}:\d{1,2}""") 148 | fun MusicTwoColumnItemRenderer.Run?.isTime(): Boolean { 149 | val text = this?.text ?: return false 150 | return timeRegex.matches(text) 151 | } 152 | 153 | @Serializable 154 | data class MusicTwoColumnItemRenderer( 155 | val thumbnail: MusicMenuTitleRendererThumbnail? = null, 156 | val thumbnailAspectRatio: String? = null, 157 | val title: Subtitle? = null, 158 | val subtitle: Subtitle? = null, 159 | val navigationEndpoint: MusicTwoColumnItemRendererNavigationEndpoint? = null, 160 | val menu: Menu? = null, 161 | val playlistItemData: PlaylistItemData? = null, 162 | val menuIconDisplayPolicy: String? = null 163 | ) { 164 | 165 | fun toMediaItemData(hl: String): Pair<YtmMediaItem, String?>? { 166 | return YtmSong( 167 | id = navigationEndpoint?.watchEndpoint?.videoId ?: return null, 168 | name = title?.runs?.firstOrNull()?.text, 169 | artists = menu?.menuRenderer?.items?.mapNotNull { item -> 170 | item.menuNavigationItemRenderer?.navigationEndpoint?.browseEndpoint?.takeIf { 171 | it.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ARTIST" 172 | }?.let { 173 | val browseId = it.browseId ?: return@let null 174 | val name = subtitle?.runs?.take(3) 175 | ?.filter { run -> 176 | val text = run.text ?: return@filter false 177 | !run.isTime() && !text.contains("plays") 178 | } 179 | ?.joinToString("") { run -> run.text ?: "" } 180 | ?.substringBeforeLast("•") 181 | ?: return@let null 182 | 183 | YtmArtist(browseId, name) 184 | } 185 | }, 186 | thumbnail_provider = thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.let { 187 | ThumbnailProvider.fromThumbnails(it) 188 | }, 189 | duration = subtitle?.runs?.find { it.isTime() }?.text?.let { 190 | parseYoutubeDurationString(it, hl) 191 | }, 192 | type = YtmSong.Type.SONG, 193 | is_explicit = menu?.menuRenderer?.title?.musicMenuTitleRenderer?.endButtons?.firstOrNull()?.likeButtonRenderer?.likeStatus == "LIKE" 194 | ) to playlistItemData?.playlistSetVideoId 195 | } 196 | 197 | 198 | @Serializable 199 | data class Menu( 200 | val menuRenderer: MenuRenderer? = null 201 | ) 202 | 203 | @Serializable 204 | data class MenuRenderer( 205 | val items: List<Item>? = null, 206 | val title: Title? = null, 207 | val accessibility: Accessibility? = null 208 | ) 209 | 210 | @Serializable 211 | data class Accessibility( 212 | val accessibilityData: AccessibilityData? = null 213 | ) 214 | 215 | @Serializable 216 | data class AccessibilityData( 217 | val label: String? = null 218 | ) 219 | 220 | @Serializable 221 | data class Item( 222 | val menuNavigationItemRenderer: MenuNavigationItemRenderer? = null 223 | ) 224 | 225 | @Serializable 226 | data class MenuNavigationItemRenderer( 227 | val text: Subtitle? = null, 228 | val icon: Icon? = null, 229 | val navigationEndpoint: MenuNavigationItemRendererNavigationEndpoint? = null, 230 | val trackingParams: String? = null 231 | ) 232 | 233 | @Serializable 234 | data class Icon( 235 | val iconType: String? = null 236 | ) 237 | 238 | @Serializable 239 | data class MenuNavigationItemRendererNavigationEndpoint( 240 | val clickTrackingParams: String? = null, 241 | val browseEndpoint: BrowseEndpoint? = null 242 | ) 243 | 244 | @Serializable 245 | data class BrowseEndpoint( 246 | val browseId: String? = null, 247 | val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null 248 | ) 249 | 250 | @Serializable 251 | data class BrowseEndpointContextSupportedConfigs( 252 | val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig? = null 253 | ) 254 | 255 | @Serializable 256 | data class BrowseEndpointContextMusicConfig( 257 | val pageType: String? = null 258 | ) 259 | 260 | @Serializable 261 | data class Subtitle( 262 | val runs: List<Run>? = null 263 | ) 264 | 265 | @Serializable 266 | data class Run( 267 | val text: String? = null 268 | ) 269 | 270 | @Serializable 271 | data class Title( 272 | val musicMenuTitleRenderer: MusicMenuTitleRenderer? = null 273 | ) 274 | 275 | @Serializable 276 | data class MusicMenuTitleRenderer( 277 | val primaryText: Subtitle? = null, 278 | val secondaryText: SecondaryText? = null, 279 | val thumbnail: MusicMenuTitleRendererThumbnail? = null, 280 | val endButtons: List<EndButton>? = null 281 | ) 282 | 283 | @Serializable 284 | data class EndButton( 285 | val likeButtonRenderer: LikeButtonRenderer? = null 286 | ) 287 | 288 | @Serializable 289 | data class LikeButtonRenderer( 290 | val target: Target? = null, 291 | val likeStatus: String? = null, 292 | val trackingParams: String? = null, 293 | val likesAllowed: Boolean? = null, 294 | val serviceEndpoints: List<ServiceEndpoint>? = null 295 | ) 296 | 297 | @Serializable 298 | data class ServiceEndpoint( 299 | val clickTrackingParams: String? = null, 300 | val likeEndpoint: LikeEndpoint? = null 301 | ) 302 | 303 | @Serializable 304 | data class LikeEndpoint( 305 | val status: String? = null, 306 | val target: Target? = null 307 | ) 308 | 309 | @Serializable 310 | data class Target( 311 | val videoId: String? = null 312 | ) 313 | 314 | @Serializable 315 | data class SecondaryText( 316 | val runs: List<Run>? = null, 317 | val accessibility: Accessibility? = null 318 | ) 319 | 320 | @Serializable 321 | data class MusicMenuTitleRendererThumbnail( 322 | val musicThumbnailRenderer: MusicThumbnailRenderer? = null 323 | ) 324 | 325 | @Serializable 326 | data class MusicThumbnailRenderer( 327 | val thumbnail: MusicThumbnailRendererThumbnail? = null, 328 | val thumbnailCrop: String? = null, 329 | val thumbnailScale: String? = null, 330 | val trackingParams: String? = null 331 | ) 332 | 333 | @Serializable 334 | data class MusicThumbnailRendererThumbnail( 335 | val thumbnails: List<Thumbnail>? = null 336 | ) 337 | 338 | 339 | @Serializable 340 | data class MusicTwoColumnItemRendererNavigationEndpoint( 341 | val watchEndpoint: WatchEndpoint? = null 342 | ) 343 | 344 | @Serializable 345 | data class WatchEndpoint( 346 | val videoId: String? = null, 347 | val playlistId: String? = null, 348 | val params: String? = null, 349 | val playlistSetVideoId: String? = null 350 | ) 351 | 352 | @Serializable 353 | data class PlaylistItemData( 354 | val playlistSetVideoId: String? = null 355 | ) 356 | 357 | } 358 | 359 | @Serializable 360 | data class MusicPlaylistShelfRenderer( 361 | val playlistId: String? = null, 362 | val contents: List<YoutubeiBrowseResponse.YoutubeiShelf.YoutubeiShelfContentsItem>? = null, 363 | val continuations: List<Continuation>? = null, 364 | val subFooter: SubFooter? = null, 365 | ) { 366 | 367 | @Serializable 368 | data class SubFooter( 369 | val messageRenderer: MessageRenderer 370 | ) 371 | 372 | @Serializable 373 | data class MessageRenderer( 374 | val subtext: Subtext 375 | ) 376 | 377 | @Serializable 378 | data class Subtext( 379 | val messageSubtextRenderer: MessageSubtextRenderer 380 | ) 381 | 382 | @Serializable 383 | data class MessageSubtextRenderer( 384 | val text: TextRuns 385 | ) 386 | 387 | @Serializable 388 | data class Continuation( 389 | val nextContinuationData: NextContinuationData? = null 390 | ) 391 | 392 | @Serializable 393 | data class NextContinuationData( 394 | val continuation: String? = null, 395 | val clickTrackingParams: String? = null, 396 | val autoloadEnabled: Boolean? = null, 397 | val autoloadImmediately: Boolean? = null 398 | ) 399 | } 400 | 401 | @Serializable 402 | data class ItemSectionRenderer(val contents: List<ItemSectionRendererContent>) 403 | 404 | @Serializable 405 | data class ItemSectionRendererContent(val didYouMeanRenderer: DidYouMeanRenderer?) 406 | 407 | @Serializable 408 | data class DidYouMeanRenderer(val correctedQuery: TextRuns) 409 | 410 | 411 | @Serializable 412 | data class GridRenderer( 413 | val items: List<YoutubeiBrowseResponse.YoutubeiShelf.YoutubeiShelfContentsItem>? = null, 414 | override val header: GridHeader? = null, 415 | ) : YoutubeiHeaderContainer 416 | 417 | @Serializable 418 | data class GridHeader(val gridHeaderRenderer: HeaderRenderer?) : YoutubeiHeader { 419 | override val header_renderer: HeaderRenderer? 420 | get() = gridHeaderRenderer 421 | } 422 | 423 | @Serializable 424 | data class MusicCarouselShelfRenderer( 425 | override val header: Header? = null, 426 | val contents: List<YoutubeiBrowseResponse.YoutubeiShelf.YoutubeiShelfContentsItem>? = null, 427 | ) : YoutubeiHeaderContainer 428 | 429 | @Serializable 430 | data class MusicDescriptionShelfRenderer(val description: TextRuns, val header: TextRuns?) 431 | 432 | @Serializable 433 | data class MusicCardShelfRenderer( 434 | val thumbnail: ThumbnailRenderer, 435 | val title: TextRuns, 436 | val subtitle: TextRuns, 437 | val menu: Menu, 438 | override val header: Header? = null 439 | ) : YoutubeiHeaderContainer 440 | 441 | @Serializable 442 | data class Menu(val menuRenderer: MenuRenderer?) 443 | 444 | @Serializable 445 | data class MenuRenderer( 446 | val items: List<YoutubeiNextResponse.MenuItem>? = null, 447 | val title: Title? = null 448 | ) 449 | 450 | @Serializable 451 | data class Title( 452 | val musicMenuTitleRenderer: MusicMenuTitleRenderer? = null 453 | ) 454 | 455 | @Serializable 456 | data class MusicMenuTitleRenderer( 457 | val primaryText: PrimaryText? = null, 458 | val secondaryText: SecondaryText? = null, 459 | val thumbnail: MusicMenuTitleRendererThumbnail? = null, 460 | val endButtons: List<EndButton>? = null 461 | ) 462 | 463 | @Serializable 464 | data class EndButton( 465 | val likeButtonRenderer: LikeButtonRenderer? = null 466 | ) 467 | 468 | @Serializable 469 | data class LikeButtonRenderer( 470 | val target: Target? = null, 471 | val likeStatus: String? = null, 472 | val likesAllowed: Boolean? = null, 473 | val serviceEndpoints: List<ServiceEndpoint>? = null 474 | ) 475 | 476 | @Serializable 477 | data class ServiceEndpoint( 478 | val likeEndpoint: LikeEndpoint? = null 479 | ) 480 | 481 | @Serializable 482 | data class LikeEndpoint( 483 | val status: String? = null, 484 | val target: Target? = null 485 | ) 486 | 487 | @Serializable 488 | data class Target( 489 | val videoId: String? = null 490 | ) 491 | 492 | @Serializable 493 | data class PrimaryText( 494 | val runs: List<Run>? = null 495 | ) 496 | 497 | @Serializable 498 | data class Run( 499 | val text: String? = null 500 | ) 501 | 502 | @Serializable 503 | data class SecondaryText( 504 | val runs: List<Run>? = null, 505 | ) 506 | 507 | @Serializable 508 | data class MusicMenuTitleRendererThumbnail( 509 | val musicThumbnailRenderer: MusicThumbnailRenderer? = null 510 | ) 511 | 512 | @Serializable 513 | data class MusicThumbnailRenderer( 514 | val thumbnail: MusicThumbnailRendererThumbnail? = null, 515 | val thumbnailCrop: String? = null, 516 | val thumbnailScale: String? = null, 517 | val trackingParams: String? = null 518 | ) 519 | 520 | @Serializable 521 | data class MusicThumbnailRendererThumbnail( 522 | val thumbnails: List<Thumbnail>? = null 523 | ) 524 | -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/endpoints/YoutubeiBrowseResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension.endpoints 2 | 3 | import dev.brahmkshatriya.echo.extension.endpoints.EchoPlaylistEndpoint.Companion.findSongCount 4 | import dev.brahmkshatriya.echo.extension.endpoints.EchoPlaylistEndpoint.Companion.findTrackCount 5 | import dev.toastbits.ytmkt.endpoint.SongFeedFilterChip 6 | import dev.toastbits.ytmkt.impl.youtubei.endpoint.ChipCloudRendererHeader 7 | import dev.toastbits.ytmkt.model.YtmApi 8 | import dev.toastbits.ytmkt.model.external.Thumbnail 9 | import dev.toastbits.ytmkt.model.external.ThumbnailProvider 10 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmArtist 11 | import dev.toastbits.ytmkt.model.external.mediaitem.YtmMediaItem 12 | import dev.toastbits.ytmkt.model.internal.HeaderRenderer 13 | import dev.toastbits.ytmkt.model.internal.MusicMultiRowListItemRenderer 14 | import dev.toastbits.ytmkt.model.internal.MusicResponsiveListItemRenderer 15 | import dev.toastbits.ytmkt.model.internal.TextRun 16 | import dev.toastbits.ytmkt.radio.YoutubeiNextResponse 17 | import dev.toastbits.ytmkt.uistrings.YoutubeUiString 18 | import dev.toastbits.ytmkt.uistrings.parseYoutubeDurationString 19 | import kotlinx.serialization.Serializable 20 | 21 | @Serializable 22 | data class YoutubeiBrowseResponse( 23 | val contents: Contents?, 24 | val continuationContents: ContinuationContents?, 25 | val header: Header? 26 | ) { 27 | val ctoken: String? 28 | get() = continuationContents?.sectionListContinuation?.continuations?.firstOrNull()?.nextContinuationData?.continuation 29 | ?: contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.continuations?.firstOrNull()?.nextContinuationData?.continuation 30 | 31 | fun getShelves(hasContinuation: Boolean): List<YoutubeiShelf> { 32 | return if (hasContinuation) continuationContents?.sectionListContinuation?.contents 33 | ?: emptyList() 34 | else contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents 35 | ?: contents?.twoColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents 36 | ?: contents?.sectionListRenderer?.contents ?: emptyList() 37 | } 38 | 39 | fun getHeaderChips(dataLanguage: String): List<SongFeedFilterChip>? = 40 | contents?.singleColumnBrowseResultsRenderer?.tabs?.first()?.tabRenderer?.content?.sectionListRenderer?.header?.chipCloudRenderer?.chips?.map { 41 | SongFeedFilterChip( 42 | YoutubeUiString.Type.FILTER_CHIP.createFromKey( 43 | it.chipCloudChipRenderer.text!!.first_text, 44 | dataLanguage 45 | ), 46 | it.chipCloudChipRenderer.navigationEndpoint.browseEndpoint!!.params!! 47 | ) 48 | } 49 | 50 | @Serializable 51 | data class Contents( 52 | val singleColumnBrowseResultsRenderer: SingleColumnBrowseResultsRenderer?, 53 | val twoColumnBrowseResultsRenderer: TwoColumnBrowseResultsRenderer?, 54 | val sectionListRenderer: SectionListRenderer? 55 | ) 56 | 57 | @Serializable 58 | data class SingleColumnBrowseResultsRenderer(val tabs: List<Tab>) 59 | 60 | @Serializable 61 | data class Tab(val tabRenderer: TabRenderer) 62 | 63 | @Serializable 64 | data class TabRenderer(val content: Content?) 65 | 66 | @Serializable 67 | data class Content(val sectionListRenderer: SectionListRenderer?) 68 | 69 | @Serializable 70 | data class SectionListRenderer( 71 | val contents: List<YoutubeiShelf>?, 72 | val header: ChipCloudRendererHeader?, 73 | val continuations: List<YoutubeiNextResponse.Continuation>? 74 | ) 75 | 76 | @Serializable 77 | data class TwoColumnBrowseResultsRenderer( 78 | val tabs: List<Tab>, 79 | val secondaryContents: SecondaryContents 80 | ) { 81 | @Serializable 82 | data class SecondaryContents(val sectionListRenderer: SectionListRenderer) 83 | } 84 | 85 | @Serializable 86 | data class YoutubeiShelf( 87 | val musicShelfRenderer: MusicShelfRenderer?, 88 | val musicPlaylistShelfRenderer: MusicPlaylistShelfRenderer?, 89 | val musicResponsiveHeaderRenderer: MusicResponsiveHeaderRenderer?, 90 | val musicEditablePlaylistDetailHeaderRenderer: MusicEditablePlaylistDetailHeaderRenderer?, 91 | val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?, 92 | val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, 93 | val musicCardShelfRenderer: MusicCardShelfRenderer?, 94 | val gridRenderer: GridRenderer?, 95 | val itemSectionRenderer: ItemSectionRenderer? 96 | ) { 97 | val title: TextRun? 98 | get() = 99 | if (musicShelfRenderer != null) musicShelfRenderer.title?.runs?.firstOrNull() 100 | else if (musicCarouselShelfRenderer != null) musicCarouselShelfRenderer.header?.getRenderer()?.title?.runs?.firstOrNull() 101 | else if (musicCardShelfRenderer != null) musicCardShelfRenderer.title.runs?.firstOrNull() 102 | else if (gridRenderer != null) gridRenderer.header?.gridHeaderRenderer?.title?.runs?.firstOrNull() 103 | else null 104 | 105 | fun getMediaItems(hl: String, api: YtmApi) = run { 106 | val contents = musicShelfRenderer?.contents 107 | ?: musicPlaylistShelfRenderer?.contents 108 | ?: musicCarouselShelfRenderer?.contents 109 | ?: gridRenderer?.items 110 | contents?.mapNotNull { it.toMediaItemData(hl, api)?.first } 111 | } 112 | 113 | @Serializable 114 | data class MusicShelfRenderer( 115 | val title: dev.toastbits.ytmkt.model.internal.TextRuns?, 116 | val contents: List<YoutubeiShelfContentsItem>? = null, 117 | val continuations: List<YoutubeiNextResponse.Continuation>? = null, 118 | val bottomEndpoint: dev.toastbits.ytmkt.model.internal.NavigationEndpoint? 119 | ) 120 | 121 | @Serializable 122 | data class YoutubeiShelfContentsItem( 123 | val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, 124 | val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, 125 | val musicMultiRowListItemRenderer: MusicMultiRowListItemRenderer?, 126 | val musicTwoColumnItemRenderer: MusicTwoColumnItemRenderer?, 127 | val continuationItemRenderer: ContinuationItemRenderer? 128 | ) { 129 | 130 | // Pair(item, playlistSetVideoId) 131 | fun toMediaItemData(hl: String, api: YtmApi): Pair<YtmMediaItem, String?>? { 132 | if (musicTwoRowItemRenderer != null) { 133 | return musicTwoRowItemRenderer.toYtmMediaItem(api)?.let { Pair(it, null) } 134 | } else if (musicResponsiveListItemRenderer != null) { 135 | return musicResponsiveListItemRenderer.toMediaItemAndPlaylistSetVideoId(hl) 136 | } else if (musicMultiRowListItemRenderer != null) { 137 | return Pair(musicMultiRowListItemRenderer.toMediaItem(hl), null) 138 | } else if (musicTwoColumnItemRenderer != null) { 139 | return musicTwoColumnItemRenderer.toMediaItemData(hl) 140 | } else if (continuationItemRenderer != null) return null 141 | return null 142 | } 143 | } 144 | 145 | fun getMediaItemsAndSetIds(hl: String, api: YtmApi) = run { 146 | val renderer = musicShelfRenderer?.contents ?: musicPlaylistShelfRenderer?.contents 147 | renderer?.mapNotNull { it.toMediaItemData(hl, api) } 148 | } 149 | 150 | fun getRenderer() = 151 | musicShelfRenderer ?: musicPlaylistShelfRenderer ?: musicCarouselShelfRenderer 152 | ?: musicDescriptionShelfRenderer ?: musicCardShelfRenderer ?: gridRenderer 153 | 154 | 155 | fun getPlaylistData(hl: String) = (musicResponsiveHeaderRenderer 156 | ?: musicEditablePlaylistDetailHeaderRenderer?.header?.musicResponsiveHeaderRenderer)?.run { 157 | val title = title?.runs?.firstOrNull()?.text ?: "Unknown" 158 | val thumbnail = thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.let { 159 | ThumbnailProvider.fromThumbnails(it) 160 | } 161 | val year = subtitle?.runs?.find { it.text.length == 4 }?.text?.toIntOrNull() 162 | val description = 163 | description?.musicDescriptionShelfRenderer?.description?.runs?.joinToString("") { 164 | it.text 165 | } 166 | val tracks = secondSubtitle?.runs?.findSongCount() 167 | ?: secondSubtitle?.runs?.findTrackCount() 168 | 169 | val duration = secondSubtitle?.runs?.lastOrNull()?.let { 170 | parseYoutubeDurationString(it.text, hl) 171 | } 172 | val isEditable = thumbnailEditButton?.buttonRenderer?.isDisabled == false 173 | val artist = facepile?.avatarStackViewModel?.let { model -> 174 | val url = 175 | model.rendererContext?.commandContext?.onTap?.innertubeCommand?.browseEndpoint?.browseId 176 | ?: return@let null 177 | val name = model.rendererContext.accessibilityContext?.label ?: "Unknown" 178 | YtmArtist(url, name) 179 | }?.let { listOf(it) } 180 | val artists = straplineTextOne?.runs?.mapNotNull { 181 | val id = it.navigationEndpoint?.browseEndpoint?.browseId ?: return@mapNotNull null 182 | YtmArtist(id, it.text) 183 | } ?: emptyList() 184 | val isExplicit = 185 | subtitleBadge?.any { it.musicInlineBadgeRenderer?.icon?.iconType == "MUSIC_EXPLICIT_BADGE" } 186 | ?: false 187 | PlaylistData( 188 | title = title, 189 | description = description, 190 | thumbnail = thumbnail, 191 | artists = artist ?: artists, 192 | year = year, 193 | explicit = isExplicit, 194 | isEditable = isEditable, 195 | count = tracks, 196 | duration = duration 197 | ) 198 | } 199 | } 200 | 201 | @Serializable 202 | data class ContinuationItemRenderer( 203 | val continuationEndpoint: ContinuationEndpoint 204 | ) 205 | 206 | @Serializable 207 | data class ContinuationEndpoint( 208 | val continuationCommand: ContinuationCommand 209 | ) 210 | 211 | @Serializable 212 | data class ContinuationCommand( 213 | val token: String 214 | ) 215 | 216 | @Serializable 217 | data class ContinuationContents( 218 | val sectionListContinuation: SectionListRenderer?, 219 | val musicPlaylistShelfContinuation: MusicPlaylistShelfRenderer?, 220 | val musicShelfContinuation: YoutubeiShelf.MusicShelfRenderer? 221 | ) 222 | 223 | data class PlaylistData( 224 | val title: String?, 225 | val description: String?, 226 | val thumbnail: ThumbnailProvider?, 227 | val artists: List<YtmArtist>, 228 | val year: Int?, 229 | val explicit: Boolean = false, 230 | val isEditable: Boolean = false, 231 | val count: Int? = null, 232 | val duration: Long? = null 233 | ) 234 | 235 | @Serializable 236 | data class Header( 237 | val musicDetailHeaderRenderer: MusicDetailHeaderRenderer? = null, 238 | val musicElementHeaderRenderer: MusicElementHeaderRenderer? = null, 239 | val musicCarouselShelfBasicHeaderRenderer: HeaderRenderer?, 240 | val musicImmersiveHeaderRenderer: HeaderRenderer?, 241 | val musicVisualHeaderRenderer: HeaderRenderer?, 242 | val musicCardShelfHeaderBasicRenderer: HeaderRenderer? 243 | ) { 244 | fun getRenderer(): HeaderRenderer? { 245 | return musicCarouselShelfBasicHeaderRenderer 246 | ?: musicImmersiveHeaderRenderer 247 | ?: musicVisualHeaderRenderer 248 | } 249 | } 250 | 251 | @Serializable 252 | data class MusicResponsiveHeaderRenderer( 253 | val thumbnail: StraplineThumbnailClass? = null, 254 | val title: TextRuns? = null, 255 | val subtitle: TextRuns? = null, 256 | val description: MusicResponsiveHeaderRendererDescription? = null, 257 | val secondSubtitle: TextRuns? = null, 258 | val thumbnailEditButton: ThumbnailEditButton? = null, 259 | val facepile: Facepile? = null, 260 | val straplineTextOne: TextRuns? = null, 261 | val straplineThumbnail: StraplineThumbnailClass? = null, 262 | val subtitleBadge: List<SubtitleBadge>? = null 263 | ) 264 | 265 | @Serializable 266 | data class MusicResponsiveHeaderRendererDescription( 267 | val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer? = null 268 | ) 269 | 270 | @Serializable 271 | data class MusicDescriptionShelfRenderer( 272 | val description: TextRuns? = null 273 | ) 274 | 275 | @Serializable 276 | data class URLEndpoint( 277 | val url: String? = null, 278 | val target: String? = null 279 | ) 280 | 281 | @Serializable 282 | data class Facepile( 283 | val avatarStackViewModel: AvatarStackViewModel? = null 284 | ) 285 | 286 | @Serializable 287 | data class AvatarStackViewModel( 288 | val rendererContext: RendererContext? = null, 289 | val text: Text? = null 290 | ) 291 | 292 | @Serializable 293 | data class RendererContext( 294 | val accessibilityContext: AccessibilityData? = null, 295 | val commandContext: CommandContext? = null 296 | ) 297 | 298 | @Serializable 299 | data class CommandContext( 300 | val onTap: OnTap? = null 301 | ) 302 | 303 | @Serializable 304 | data class OnTap( 305 | val innertubeCommand: InnertubeCommandClass? = null 306 | ) 307 | 308 | @Serializable 309 | data class InnertubeCommandClass( 310 | val browseEndpoint: BrowseEndpoint? = null 311 | ) 312 | 313 | @Serializable 314 | data class Text( 315 | val content: String? = null 316 | ) 317 | 318 | @Serializable 319 | data class TextRuns( 320 | val runs: List<TextRun>? = null 321 | ) 322 | 323 | @Serializable 324 | data class StraplineThumbnailClass( 325 | val musicThumbnailRenderer: MusicThumbnailRenderer? = null 326 | ) 327 | 328 | @Serializable 329 | data class ThumbnailEditButton( 330 | val buttonRenderer: ButtonRenderer? = null 331 | ) 332 | 333 | @Serializable 334 | data class ButtonRenderer( 335 | val isDisabled: Boolean? = null 336 | ) 337 | 338 | @Serializable 339 | data class MusicDetailHeaderRenderer( 340 | val title: TextRuns? = null, 341 | val subtitle: TextRuns? = null, 342 | val byline: Byline? = null, 343 | val menu: Menu? = null, 344 | val thumbnail: MusicDetailHeaderRendererThumbnail? = null, 345 | val subtitleBadges: List<SubtitleBadge>? = null, 346 | val secondTitle: SecondTitle? = null 347 | ) 348 | 349 | @Serializable 350 | data class Byline( 351 | val musicDetailHeaderButtonsBylineRenderer: MusicDetailHeaderButtonsBylineRenderer? = null 352 | ) 353 | 354 | @Serializable 355 | data class MusicDetailHeaderButtonsBylineRenderer( 356 | val description: TextRuns? = null 357 | ) 358 | 359 | @Serializable 360 | data class Menu( 361 | val menuRenderer: MenuRenderer? = null 362 | ) 363 | 364 | @Serializable 365 | data class MenuRenderer( 366 | val trackingParams: String? = null, 367 | val title: Title? = null 368 | ) 369 | 370 | @Serializable 371 | data class Title( 372 | val musicMenuTitleRenderer: MusicMenuTitleRenderer? = null 373 | ) 374 | 375 | @Serializable 376 | data class MusicMenuTitleRenderer( 377 | val primaryText: TextRuns? = null, 378 | val secondaryText: SecondaryText? = null, 379 | val thumbnail: MusicMenuTitleRendererThumbnail? = null 380 | ) 381 | 382 | @Serializable 383 | data class SecondaryText( 384 | val runs: List<TextRun>? = null, 385 | val accessibility: Accessibility? = null 386 | ) 387 | 388 | @Serializable 389 | data class Accessibility( 390 | val accessibilityData: AccessibilityData? = null 391 | ) 392 | 393 | @Serializable 394 | data class AccessibilityData( 395 | val label: String? = null 396 | ) 397 | 398 | @Serializable 399 | data class MusicMenuTitleRendererThumbnail( 400 | val musicThumbnailRenderer: MusicThumbnailRenderer? = null 401 | ) 402 | 403 | @Serializable 404 | data class MusicThumbnailRenderer( 405 | val thumbnail: MusicThumbnailRendererThumbnail? = null, 406 | val thumbnailCrop: String? = null, 407 | val thumbnailScale: String? = null, 408 | val trackingParams: String? = null 409 | ) 410 | 411 | @Serializable 412 | data class MusicThumbnailRendererThumbnail( 413 | val thumbnails: List<Thumbnail>? = null 414 | ) 415 | 416 | @Serializable 417 | data class SecondTitle( 418 | val runs: List<SecondTitleRun>? = null 419 | ) 420 | 421 | @Serializable 422 | data class SecondTitleRun( 423 | val text: String? = null, 424 | val navigationEndpoint: NavigationEndpoint? = null 425 | ) 426 | 427 | @Serializable 428 | data class NavigationEndpoint( 429 | val clickTrackingParams: String? = null, 430 | val browseEndpoint: BrowseEndpoint? = null, 431 | val urlEndpoint: URLEndpoint? = null 432 | ) 433 | 434 | @Serializable 435 | data class BrowseEndpoint( 436 | val browseId: String? = null, 437 | val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null 438 | ) 439 | 440 | @Serializable 441 | data class BrowseEndpointContextSupportedConfigs( 442 | val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig? = null 443 | ) 444 | 445 | @Serializable 446 | data class BrowseEndpointContextMusicConfig( 447 | val pageType: String? = null 448 | ) 449 | 450 | @Serializable 451 | data class SubtitleBadge( 452 | val musicInlineBadgeRenderer: MusicInlineBadgeRenderer? = null 453 | ) 454 | 455 | @Serializable 456 | data class MusicInlineBadgeRenderer( 457 | val trackingParams: String? = null, 458 | val icon: Icon? = null, 459 | val accessibilityData: Accessibility? = null 460 | ) 461 | 462 | @Serializable 463 | data class Icon( 464 | val iconType: String? = null 465 | ) 466 | 467 | @Serializable 468 | data class MusicDetailHeaderRendererThumbnail( 469 | val croppedSquareThumbnailRenderer: CroppedSquareThumbnailRenderer? = null 470 | ) 471 | 472 | @Serializable 473 | data class CroppedSquareThumbnailRenderer( 474 | val thumbnail: MusicThumbnailRendererThumbnail? = null 475 | ) 476 | 477 | @Serializable 478 | data class MusicEditablePlaylistDetailHeaderRenderer( 479 | val header: YoutubeiShelf? = null, 480 | val editHeader: EditHeader? = null, 481 | val trackingParams: String? = null, 482 | val playlistId: String? = null 483 | ) 484 | 485 | @Serializable 486 | data class EditHeader( 487 | val musicPlaylistEditHeaderRenderer: MusicPlaylistEditHeaderRenderer? = null 488 | ) 489 | 490 | @Serializable 491 | data class MusicPlaylistEditHeaderRenderer( 492 | val title: TextRuns? = null, 493 | val editTitle: TextRuns? = null, 494 | val description: TextRuns? = null, 495 | val editDescription: TextRuns? = null, 496 | val privacy: String? = null, 497 | val playlistId: String? = null, 498 | val metadataFieldsDisabled: Boolean? = null 499 | ) 500 | 501 | @Serializable 502 | data class MusicElementHeaderRenderer( 503 | val elementRenderer: MusicElementHeaderRendererElementRenderer? = null, 504 | val useSplitScreenLayoutOnCompatibleScreenSizes: Boolean? = null 505 | ) 506 | 507 | @Serializable 508 | data class MusicElementHeaderRendererElementRenderer( 509 | val elementRenderer: ElementRendererElementRenderer? = null 510 | ) 511 | 512 | @Serializable 513 | data class ElementRendererElementRenderer( 514 | val newElement: NewElement? = null 515 | ) 516 | 517 | @Serializable 518 | data class NewElement( 519 | val type: Type? = null 520 | ) 521 | 522 | @Serializable 523 | data class Type( 524 | val componentType: ComponentType? = null 525 | ) 526 | 527 | @Serializable 528 | data class ComponentType( 529 | val model: Model? = null 530 | ) 531 | 532 | @Serializable 533 | data class Model( 534 | val musicBlurredBackgroundHeaderModel: MusicBlurredBackgroundHeaderModel? = null 535 | ) 536 | 537 | @Serializable 538 | data class MusicBlurredBackgroundHeaderModel( 539 | val data: Data? = null 540 | ) 541 | 542 | @Serializable 543 | data class Data( 544 | val title: String? = null, 545 | val primaryImage: Image? = null, 546 | val backgroundImage: Image? = null, 547 | val formattedDescription: FormattedDescription? = null, 548 | val straplineData: StraplineData? = null, 549 | val titleMaxLines: Long? = null, 550 | val formattedTitle: FormattedTitle? = null 551 | ) 552 | 553 | @Serializable 554 | data class Image( 555 | val sources: List<Thumbnail>? = null 556 | ) 557 | 558 | @Serializable 559 | data class FormattedDescription( 560 | val content: String? = null, 561 | val lineSpacing: Long? = null, 562 | val styleRuns: List<StyleRun>? = null 563 | ) 564 | 565 | @Serializable 566 | data class StyleRun( 567 | val startIndex: Long? = null, 568 | val length: Long? = null, 569 | val fontSize: Long? = null, 570 | val fontColor: Long? = null 571 | ) 572 | 573 | @Serializable 574 | data class FormattedTitle( 575 | val content: String? = null 576 | ) 577 | 578 | @Serializable 579 | data class StraplineData( 580 | val textLine1: TextLine? = null, 581 | val textLine2: TextLine? = null, 582 | val avatarImage: Image? = null, 583 | val headerOnTapCommand: HeaderOnTapCommand? = null 584 | ) 585 | 586 | @Serializable 587 | data class HeaderOnTapCommand( 588 | val innertubeCommand: NavigationEndpoint? = null 589 | ) 590 | 591 | @Serializable 592 | data class TextLine( 593 | val content: String? = null, 594 | val styleRuns: List<StyleRun>? = null 595 | ) 596 | 597 | } 598 | --------------------------------------------------------------------------------