├── 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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
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? = 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? = 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