From d28f806e5702df68cc2962eddc198eb83db21b09 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 25 Jun 2022 13:11:36 +0800 Subject: [PATCH 001/189] [Add] InnerTube --- app/build.gradle.kts | 2 + app/src/main/res/values-es/strings.xml | 156 +++++++++++++++ gradle.properties | 5 +- innertube/.gitignore | 1 + innertube/build.gradle.kts | 27 +++ .../java/com/zionhuang/innertube/InnerTube.kt | 164 ++++++++++++++++ .../java/com/zionhuang/innertube/YouTube.kt | 109 +++++++++++ .../innertube/encoder/BrotliEncoder.kt | 21 ++ .../innertube/models/BrowseResult.kt | 9 + .../com/zionhuang/innertube/models/Button.kt | 15 ++ .../zionhuang/innertube/models/Constants.kt | 5 + .../com/zionhuang/innertube/models/Context.kt | 16 ++ .../innertube/models/Continuation.kt | 21 ++ .../com/zionhuang/innertube/models/Filter.kt | 10 + .../innertube/models/GridRenderer.kt | 16 ++ .../com/zionhuang/innertube/models/Icon.kt | 8 + .../com/zionhuang/innertube/models/Info.kt | 79 ++++++++ .../com/zionhuang/innertube/models/Item.kt | 183 ++++++++++++++++++ .../com/zionhuang/innertube/models/Locale.kt | 9 + .../com/zionhuang/innertube/models/Menu.kt | 43 ++++ .../models/MusicCarouselShelfRenderer.kt | 32 +++ .../models/MusicDescriptionShelfRenderer.kt | 11 ++ .../models/MusicNavigationButtonRenderer.kt | 24 +++ .../models/MusicPlaylistShelfRenderer.kt | 10 + .../innertube/models/MusicQueueRenderer.kt | 13 ++ .../models/MusicResponsiveListItemRenderer.kt | 64 ++++++ .../innertube/models/MusicShelfRenderer.kt | 25 +++ .../models/MusicTwoRowItemRenderer.kt | 35 ++++ .../zionhuang/innertube/models/NextResult.kt | 9 + .../innertube/models/PlaylistPanelRenderer.kt | 21 ++ .../models/PlaylistPanelVideoRenderer.kt | 27 +++ .../innertube/models/PlaylistSongInfo.kt | 10 + .../com/zionhuang/innertube/models/Runs.kt | 40 ++++ .../innertube/models/SearchAllTypeResult.kt | 9 + .../innertube/models/SearchResult.kt | 9 + .../SearchSuggestionsSectionRenderer.kt | 41 ++++ .../com/zionhuang/innertube/models/Section.kt | 35 ++++ .../innertube/models/SectionListRenderer.kt | 80 ++++++++ .../innertube/models/SuggestionItem.kt | 21 ++ .../com/zionhuang/innertube/models/Tabs.kt | 27 +++ .../innertube/models/ThumbnailRenderer.kt | 45 +++++ .../zionhuang/innertube/models/Thumbnails.kt | 17 ++ .../innertube/models/YouTubeClient.kt | 50 +++++ .../innertube/models/body/BrowseBody.kt | 11 ++ .../innertube/models/body/GetQueueBody.kt | 11 ++ .../models/body/GetSearchSuggestionsBody.kt | 10 + .../innertube/models/body/NextBody.kt | 15 ++ .../innertube/models/body/PlayerBody.kt | 11 ++ .../innertube/models/body/SearchBody.kt | 11 ++ .../innertube/models/endpoints/Endpoint.kt | 55 ++++++ .../models/endpoints/NavigationEndpoint.kt | 14 ++ .../models/response/BrowseResponse.kt | 61 ++++++ .../models/response/GetQueueResponse.kt | 14 ++ .../response/GetSearchSuggestionsResponse.kt | 14 ++ .../innertube/models/response/NextResponse.kt | 38 ++++ .../models/response/PlayerResponse.kt | 74 +++++++ .../models/response/SearchResponse.kt | 36 ++++ .../zionhuang/innertube/utils/TimeParser.kt | 14 ++ innertube/src/test/java/YouTubeTest.kt | 144 ++++++++++++++ settings.gradle.kts | 3 +- 60 files changed, 2088 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 innertube/.gitignore create mode 100644 innertube/build.gradle.kts create mode 100644 innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/YouTube.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/encoder/BrotliEncoder.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Button.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Constants.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Context.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Info.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Item.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicPlaylistShelfRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicQueueRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/PlaylistSongInfo.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/SearchAllTypeResult.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/SearchResult.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Section.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/SuggestionItem.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Tabs.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/body/BrowseBody.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/body/GetQueueBody.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/body/GetSearchSuggestionsBody.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/body/NextBody.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/body/SearchBody.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/endpoints/Endpoint.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/endpoints/NavigationEndpoint.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/GetQueueResponse.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/GetSearchSuggestionsResponse.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/NextResponse.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt create mode 100644 innertube/src/test/java/YouTubeTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3e58fa947..0b4cbacf0 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -141,6 +141,8 @@ dependencies { implementation("androidx.room:room-ktx:2.4.2") implementation("androidx.room:room-paging:2.4.2") testImplementation("androidx.room:room-testing:2.4.2") + // YouTube API + implementation(project(mapOf("path" to ":innertube"))) // NewPipe Extractor implementation("com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751") implementation("com.github.TeamNewPipe:NewPipeExtractor:v$newpipeVersion") diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 000000000..5490ebcd2 --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,156 @@ + + + Canciones + Artistas + Canales + Listas de reproducción + Explorar + Ajustes + Reproduciendo + Actualizar + + + Apariencia + Seguir el tema del sistema + Color del tema + Tema oscuro + Encendido + Apagado + Seguir el sistema + Contenido + Idioma de contenido predeterminado + País de contenido predeterminado + Descarga automatica + Descargar canción cuando se agrega a la biblioteca + Agregar canción automáticamente a la biblioteca + Agregue una canción a su biblioteca cuando termine de reproducirse + Expandir reproductor inferior al reproducir + Acerca de + Version de la app + NewPipe Extractor version + Buscar actualizaciones + Buscando… + Estas actualizado. + Algo salió mal. + Actualización disponible + + + Sakura + Rojo + Rosa + Morado + Morado oscuro + Indigo + Azul + Azul claro + Cian + Verde azulado + Verde + Verde claro + Lima + Amarillo + Ambar + Naranjo + Naranjo oscuro + Cafe + Gris azulado + + + Buscar + + + Agregar a la biblioteca + Descargar + Editar + Reproducir siguiente + Añadir a la cola + Agregar a la lista de reproducción + Descargar + Quitar descarga + Borrar + + + Editar cancion + Título de la canción + Artista de la canción + El título de la canción no puede estar vacío. + El artista de la canción no puede estar vacío. + Guardar + + Crear lista de reproducción + Nombre de la lista de reproducción + El nombre de la lista de reproducción no puede estar vacío. + + Editar artista + Nombre del artista + El nombre del artista no puede estar vacío. + + Artistas duplicados + El artista %1$s ya existe. ¿Fusionar artistas? + + Elegir lista de reproducción + + Editar lista de reproducción + + + Reproductor de música + Descargar + + + + %d cancion + %d cancion + %d canciones + + + %d suscriptor + %d suscriptor + %d suscriptores + + + %d video + %d videos + + + + Volver a intentar + Reproducir todo + Shuffle + + + Nombre + Artista + Fecha Agregada + + + + %d canción ha sido eliminada. + %d canciónes han sido eliminadas. + + + %d seleccionadas + + Deshacer + + + Agregar a la biblioteca + + + Todo + Canciones + Videos + Albums + Artistas + Listas de reproducción + Canales + + Sistema por defecto + Buscar para encontrar algo de música + + + Que hay de nuevo + Actualizar + Preparando… + Verificando… + Descargando… (%1$s/%2$s) + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index ddac11571..ec831cb2a 100755 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,7 @@ org.gradle.jvmargs=-Xmx1536m # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=true + +ktor_version=2.0.0 +logback_version=1.2.11 \ No newline at end of file diff --git a/innertube/.gitignore b/innertube/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/innertube/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/innertube/build.gradle.kts b/innertube/build.gradle.kts new file mode 100644 index 000000000..075805719 --- /dev/null +++ b/innertube/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("jvm") + id("kotlinx-serialization") +} + +val ktor_version: String by project +val logback_version: String by project + +tasks.withType().configureEach { + kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" +} + +dependencies { + implementation("io.ktor:ktor-client-core:$ktor_version") + implementation("io.ktor:ktor-client-cio:$ktor_version") + + implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + implementation("io.ktor:ktor-client-encoding:$ktor_version") + + implementation("ch.qos.logback:logback-classic:$logback_version") + implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") + + implementation("org.brotli:dec:0.1.2") + + testImplementation("junit:junit:4.13.2") +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt new file mode 100644 index 000000000..a01928baa --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -0,0 +1,164 @@ +package com.zionhuang.innertube + +import com.zionhuang.innertube.encoder.brotli +import com.zionhuang.innertube.models.Locale +import com.zionhuang.innertube.models.YouTubeClient +import com.zionhuang.innertube.models.body.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.compression.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json + +/** + * Provide access to InnerTube endpoints. + * For making HTTP requests, not parsing response. + */ +class InnerTube( + private val locale: Locale, +) { + @OptIn(ExperimentalSerializationApi::class) + val httpClient = HttpClient(CIO) { + expectSuccess = true + + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + }) + } + + install(ContentEncoding) { + brotli(1.0F) + gzip(0.9F) + deflate(0.8F) + } + + //install(Logging) + + defaultRequest { + url("https://music.youtube.com/youtubei/v1/") + } + } + + private fun HttpRequestBuilder.configYTClient(client: YouTubeClient) { + contentType(ContentType.Application.Json) + headers { + append("X-Goog-Api-Format-Version", "1") + append("X-YouTube-Client-Name", client.clientName) + append("X-YouTube-Client-Version", client.clientVersion) + if (client.referer != null) { + append("Referer", client.referer) + } + } + userAgent(client.userAgent) + parameter("key", client.api_key) + parameter("prettyPrint", false) + } + + suspend fun search( + client: YouTubeClient, + query: String? = null, + params: String? = null, + continuation: String? = null, + ) = httpClient.post("search") { + configYTClient(client) + setBody(SearchBody( + context = client.toContext(locale), + query = query, + params = params + )) + parameter("continuation", continuation) + parameter("ctoken", continuation) + } + + suspend fun player( + client: YouTubeClient, + videoId: String, + playlistId: String?, + ) = httpClient.post("player") { + configYTClient(client) + setBody(PlayerBody( + context = client.toContext(locale), + videoId = videoId, + playlistId = playlistId + )) + } + + suspend fun browse( + client: YouTubeClient, + browseId: String, + params: String?, + continuation: String?, + ) = httpClient.post("browse") { + configYTClient(client) + setBody(BrowseBody( + context = client.toContext(locale), + browseId = browseId, + params = params + )) + } + + suspend fun next( + client: YouTubeClient, + videoId: String, + playlistId: String?, + playlistSetVideoId: String?, + index: Int?, + params: String?, + continuation: String? = null, + ) = httpClient.post("next") { + configYTClient(client) + setBody(NextBody( + context = client.toContext(locale), + videoId = videoId, + playlistId = playlistId, + playlistSetVideoId = playlistSetVideoId, + index = index, + params = params, + continuation = continuation + )) + } + + suspend fun getSearchSuggestions( + client: YouTubeClient, + input: String, + ) = httpClient.post("music/get_search_suggestions") { + configYTClient(client) + setBody(GetSearchSuggestionsBody( + context = client.toContext(locale), + input = input + )) + } + + suspend fun getQueue( + client: YouTubeClient, + videoIds: List?, + playlistId: String?, + ) = httpClient.post("music/get_queue") { + configYTClient(client) + setBody(GetQueueBody( + context = client.toContext(locale), + videoIds = videoIds, + playlistId = playlistId + )) + } + + companion object { + const val SONG_PARAM = "EgWKAQIIAWoMEAMQDhAEEAkQChAF" + const val FEATURED_PLAYLIST_PARAM = "EgeKAQQoADgBagwQAxAOEAQQCRAKEAU%3D" + const val VIDEO_PARAM = "EgWKAQIQAWoMEAMQDhAEEAkQChAF" + const val ALBUM_PARAM = "EgWKAQIYAWoMEAMQDhAEEAkQChAF" + const val COMMUNITY_PLAYLIST_PARAM = "EgeKAQQoAEABagwQAxAOEAQQCRAKEAU%3D" + const val ARTIST_PARAM = "EgWKAQIgAWoMEAMQDhAEEAkQChAF" + + const val HOME_BROWSE_ID = "FEmusic_home" + const val EXPLORE_BROWSE_ID = "FEmusic_explore" + } +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt new file mode 100644 index 000000000..1eb235534 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -0,0 +1,109 @@ +package com.zionhuang.innertube + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.YouTubeClient.Companion.ANDROID_MUSIC +import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX +import com.zionhuang.innertube.models.response.* +import io.ktor.client.call.* + +/** + * Parse useful data with [InnerTube] sending requests. + */ +class YouTube(locale: Locale) { + private val innerTube = InnerTube(locale) + + suspend fun getSearchSuggestions(query: String): List = + innerTube.getSearchSuggestions(ANDROID_MUSIC, query).body() + .contents.flatMap { section -> + section.searchSuggestionsSectionRenderer.contents.map { it.toSuggestionItem() } + } + + suspend fun searchAllType(query: String): SearchAllTypeResult { + val response = innerTube.search(WEB_REMIX, query).body() + return SearchAllTypeResult( + filters = response.contents!!.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content!!.sectionListRenderer!!.header?.chipCloudRenderer?.chips + ?.filter { it.chipCloudChipRenderer.text != null } + ?.map { it.toFilter() }, + sections = response.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content!!.sectionListRenderer!!.contents + .map { it.toItemSection() } + ) + } + + suspend fun search(query: String, params: String): SearchResult { + val response = innerTube.search(WEB_REMIX, query, params).body() + return SearchResult( + items = response.contents!!.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content!!.sectionListRenderer!!.contents[0].musicShelfRenderer!!.contents + .map { it.toItem() }, + continuation = response.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content!!.sectionListRenderer!!.contents[0].musicShelfRenderer!!.continuations?.getContinuation() + ) + } + + suspend fun search(continuation: String): SearchResult { + val response = innerTube.search(WEB_REMIX, continuation = continuation).body() + return SearchResult( + items = response.continuationContents?.musicShelfContinuation?.contents?.map { it.toItem() }.orEmpty(), + continuation = response.continuationContents?.musicShelfContinuation?.continuations?.getContinuation() + ) + } + + suspend fun player(videoId: String, playlistId: String? = null): PlayerResponse = + innerTube.player(ANDROID_MUSIC, videoId, playlistId).body() + + suspend fun browse(browseId: String, params: String? = null, continuation: String? = null): BrowseResponse = + innerTube.browse(WEB_REMIX, browseId, params, continuation).body() + + + /** + * Calling "next" endpoint without continuation + * @return lyricsEndpoint, relatedEndpoint + */ + suspend fun getPlaylistSongInfo( + videoId: String, + playlistId: String? = null, + playlistSetVideoId: String? = null, + index: Int? = null, + params: String? = null, + ): PlaylistSongInfo { + val response = innerTube.next(WEB_REMIX, videoId, playlistId, playlistSetVideoId, index, params).body() + return PlaylistSongInfo( + lyricsEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[1].tabRenderer.endpoint!!.browseEndpoint!!, + relatedEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[2].tabRenderer.endpoint!!.browseEndpoint!!, + ) + } + + /** + * Calling "next" endpoint, either ([index] == 0) or ([continuation] != null) + */ + suspend fun getPlaylistItems( + videoId: String, + playlistId: String? = null, + playlistSetVideoId: String? = null, + index: Int? = null, + params: String? = null, + continuation: String? = null, + ): NextResult { + val response = innerTube.next(WEB_REMIX, videoId, playlistId, playlistSetVideoId, index, params, continuation).body() + return when { + response.continuationContents != null -> NextResult( + items = response.continuationContents.playlistPanelContinuation.contents + .map { it.playlistPanelVideoRenderer.toSongItem() }, + continuation = response.continuationContents.playlistPanelContinuation.continuations.getContinuation() + ) + else -> NextResult( + items = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0].tabRenderer.content!!.musicQueueRenderer?.content?.playlistPanelRenderer?.contents + ?.map { it.playlistPanelVideoRenderer.toSongItem() } ?: emptyList(), + continuation = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0].tabRenderer.content!!.musicQueueRenderer?.content?.playlistPanelRenderer?.continuations?.getContinuation() + ) + } + } + + suspend fun getQueue(videoIds: List? = null, playlistId: String? = null): List { + if (videoIds != null) { + assert(videoIds.size <= 1000) // Max video limit + } + return innerTube.getQueue(WEB_REMIX, videoIds, playlistId).body() + .queueDatas.map { + it.content.playlistPanelVideoRenderer.toSongItem() + } + } +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/encoder/BrotliEncoder.kt b/innertube/src/main/java/com/zionhuang/innertube/encoder/BrotliEncoder.kt new file mode 100644 index 000000000..43aa34f0f --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/encoder/BrotliEncoder.kt @@ -0,0 +1,21 @@ +package com.zionhuang.innertube.encoder + +import io.ktor.client.plugins.compression.* +import io.ktor.utils.io.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.CoroutineScope +import org.brotli.dec.BrotliInputStream + +object BrotliEncoder : ContentEncoder { + override val name: String = "br" + + override fun CoroutineScope.decode(source: ByteReadChannel): ByteReadChannel = + BrotliInputStream(source.toInputStream()).toByteReadChannel() + + override fun CoroutineScope.encode(source: ByteReadChannel): ByteReadChannel = + throw UnsupportedOperationException("Encode not implemented by the library yet.") +} + +fun ContentEncoding.Config.brotli(quality: Float? = null) { + customEncoder(BrotliEncoder, quality) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt b/innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt new file mode 100644 index 000000000..8ead923c8 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt @@ -0,0 +1,9 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class BrowseResult( + val sections: List
, + val continuation: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Button.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Button.kt new file mode 100644 index 000000000..4c061645d --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Button.kt @@ -0,0 +1,15 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class Button( + val buttonRenderer: ButtonRenderer, +) { + @Serializable + data class ButtonRenderer( + val text: Runs, + val navigationEndpoint: NavigationEndpoint, + ) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Constants.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Constants.kt new file mode 100644 index 000000000..d63abcfa1 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Constants.kt @@ -0,0 +1,5 @@ +package com.zionhuang.innertube.models + +const val MUSIC_PAGE_TYPE_PLAYLIST = "MUSIC_PAGE_TYPE_PLAYLIST" +const val MUSIC_PAGE_TYPE_ARTIST = "MUSIC_PAGE_TYPE_ARTIST" +const val MUSIC_PAGE_TYPE_ALBUM = "MUSIC_PAGE_TYPE_ALBUM" \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Context.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Context.kt new file mode 100644 index 000000000..d56a0ae8a --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Context.kt @@ -0,0 +1,16 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Context( + val client: Client, +) { + @Serializable + data class Client( + val clientName: String, + val clientVersion: String, + val gl: String, + val hl: String, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt new file mode 100644 index 000000000..a4dd62eba --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.zionhuang.innertube.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +data class Continuation( + @JsonNames("nextContinuationData", "nextRadioContinuationData") + val nextContinuationData: NextContinuationData, +) { + @Serializable + data class NextContinuationData( + val continuation: String, + ) +} + +fun List.getContinuation() = + get(0).nextContinuationData.continuation \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt new file mode 100644 index 000000000..936c986e2 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt @@ -0,0 +1,10 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.SearchEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class Filter( + val text: String, + val searchEndpoint: SearchEndpoint, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt new file mode 100644 index 000000000..5b76ac7a0 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt @@ -0,0 +1,16 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class GridRenderer( + val items: List, +) { + @Serializable + data class Item( + val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?, + val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, + ) { + fun toItem() = musicNavigationButtonRenderer?.toItem() ?: musicTwoRowItemRenderer?.toItem()!! + } +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt new file mode 100644 index 000000000..529851357 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt @@ -0,0 +1,8 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Icon( + val iconType: String, +) \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Info.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Info.kt new file mode 100644 index 000000000..8821a5577 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Info.kt @@ -0,0 +1,79 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.WatchEndpoint +import com.zionhuang.innertube.models.response.BrowseResponse +import kotlinx.serialization.Serializable + +@Serializable +sealed class Info { + interface FromBrowseResponse { + fun from(response: BrowseResponse): T + } +} + +@Serializable +data class ArtistInfo( + val name: String, + val description: String, + val bannerThumbnail: List, + val shuffleEndpoint: WatchEndpoint, + val radioEndpoint: WatchEndpoint, + val contents: List
, +) : Info() { + companion object : FromBrowseResponse { + override fun from(response: BrowseResponse): ArtistInfo = ArtistInfo( + name = response.header!!.musicImmersiveHeaderRenderer!!.title.toString(), + description = response.header.musicImmersiveHeaderRenderer!!.description.toString(), + bannerThumbnail = response.header.musicImmersiveHeaderRenderer.thumbnail.getThumbnails(), + shuffleEndpoint = response.header.musicImmersiveHeaderRenderer.playButton.buttonRenderer.navigationEndpoint.watchEndpoint!!, + radioEndpoint = response.header.musicImmersiveHeaderRenderer.startRadioButton.buttonRenderer.navigationEndpoint.watchEndpoint!!, + contents = response.toSectionList() + ) + } +} + +@Serializable +data class AlbumInfo( + val name: String, + val subtitle: String, + val secondSubtitle: String, + val description: String, + val items: List, + val thumbnail: List, +) : Info() { + companion object : FromBrowseResponse { + override fun from(response: BrowseResponse): AlbumInfo = AlbumInfo( + name = response.header!!.musicDetailHeaderRenderer!!.title.toString(), + subtitle = response.header.musicDetailHeaderRenderer!!.subtitle.toString(), + secondSubtitle = response.header.musicDetailHeaderRenderer.secondSubtitle.toString(), + description = response.header.musicDetailHeaderRenderer.description.toString(), + items = response.contents.singleColumnBrowseResultsRenderer!!.tabs[0].tabRenderer.content!!.sectionListRenderer!!.contents[0].musicShelfRenderer!!.contents.map { + SongItem.from(it.musicResponsiveListItemRenderer) + }, + thumbnail = response.header.musicDetailHeaderRenderer.thumbnail.getThumbnails() + ) + } +} + +@Serializable +data class PlaylistInfo( + val name: String, + val subtitle: String, + val secondSubtitle: String, + val description: String, + val thumbnail: List, +) : Info() { + companion object : FromBrowseResponse { + override fun from(response: BrowseResponse): PlaylistInfo = PlaylistInfo( + name = response.header!!.musicDetailHeaderRenderer!!.title.toString(), + subtitle = response.header.musicDetailHeaderRenderer!!.subtitle.toString(), + secondSubtitle = response.header.musicDetailHeaderRenderer.secondSubtitle.toString(), + description = response.header.musicDetailHeaderRenderer.description.toString(), + thumbnail = response.header.musicDetailHeaderRenderer.thumbnail.getThumbnails() + ) + } +} + +fun BrowseResponse.toArtistInfo() = ArtistInfo.from(this) +fun BrowseResponse.toAlbumInfo() = AlbumInfo.from(this) +fun BrowseResponse.toPlaylistInfo() = PlaylistInfo.from(this) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Item.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Item.kt new file mode 100644 index 000000000..68f8475c7 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Item.kt @@ -0,0 +1,183 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.BrowseEndpoint +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +sealed class Item { + abstract val title: String + abstract val navigationEndpoint: NavigationEndpoint + + interface FromContent { + fun from(item: MusicResponsiveListItemRenderer): T + fun from(item: MusicTwoRowItemRenderer): T + } +} + +@Serializable +data class SongItem( + override val title: String, + val subtitle: String, + val index: String? = null, + val artistEndpoint: BrowseEndpoint?, + val albumEndpoint: BrowseEndpoint?, + val thumbnails: List, + override val navigationEndpoint: NavigationEndpoint, +) : Item() { + companion object : FromContent { + override fun from(item: MusicResponsiveListItemRenderer): SongItem { + return SongItem( + title = item.getTitle(), + subtitle = item.getSubtitle(), + index = item.index?.toString(), + artistEndpoint = item.menu.getArtistEndpoint(), + albumEndpoint = item.menu.getAlbumEndpoint(), + thumbnails = item.thumbnail?.getThumbnails().orEmpty(), + navigationEndpoint = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!! + ) + } + + override fun from(item: MusicTwoRowItemRenderer): SongItem = SongItem( + title = item.title.toString(), + subtitle = item.subtitle.toString(), + artistEndpoint = item.menu.getArtistEndpoint(), + albumEndpoint = item.menu.getAlbumEndpoint(), + thumbnails = item.thumbnailRenderer.getThumbnails(), + navigationEndpoint = item.navigationEndpoint + ) + } +} + +@Serializable +data class VideoItem( + override val title: String, + val subtitle: String, + val artistEndpoint: BrowseEndpoint?, + val albumEndpoint: BrowseEndpoint?, + val thumbnails: List, + override val navigationEndpoint: NavigationEndpoint, +) : Item() { + companion object : FromContent { + override fun from(item: MusicResponsiveListItemRenderer): VideoItem = VideoItem( + title = item.getTitle(), + subtitle = item.getSubtitle(), + artistEndpoint = item.menu.getArtistEndpoint(), // fallback: get by subtitle + albumEndpoint = item.menu.getAlbumEndpoint(), + thumbnails = item.thumbnail!!.getThumbnails(), + navigationEndpoint = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!! + ) + + override fun from(item: MusicTwoRowItemRenderer): VideoItem = VideoItem( + title = item.title.toString(), + subtitle = item.subtitle.toString(), + artistEndpoint = item.menu.getArtistEndpoint(), + albumEndpoint = item.menu.getAlbumEndpoint(), + thumbnails = item.thumbnailRenderer.getThumbnails(), + navigationEndpoint = item.navigationEndpoint + ) + } +} + +@Serializable +data class AlbumItem( + override val title: String, + val subtitle: String, + val shuffleEndpoint: NavigationEndpoint, + val radioEndpoint: NavigationEndpoint, + val artistEndpoint: BrowseEndpoint?, + val thumbnails: List, + override val navigationEndpoint: NavigationEndpoint, +) : Item() { + companion object : FromContent { + override fun from(item: MusicResponsiveListItemRenderer): AlbumItem = AlbumItem( + title = item.getTitle(), + subtitle = item.getSubtitle(), + shuffleEndpoint = item.menu.getShuffleEndpoint()!!, + radioEndpoint = item.menu.getRadioEndpoint()!!, + artistEndpoint = item.menu.getArtistEndpoint(), + thumbnails = item.thumbnail!!.getThumbnails(), + navigationEndpoint = item.navigationEndpoint!! + ) + + override fun from(item: MusicTwoRowItemRenderer): AlbumItem = AlbumItem( + title = item.title.toString(), + subtitle = item.subtitle.toString(), + shuffleEndpoint = item.menu.getShuffleEndpoint()!!, + radioEndpoint = item.menu.getRadioEndpoint()!!, + artistEndpoint = item.menu.getArtistEndpoint(), + thumbnails = item.thumbnailRenderer.getThumbnails(), + navigationEndpoint = item.navigationEndpoint + ) + } +} + +@Serializable +data class PlaylistItem( + override val title: String, + val subtitle: String, + val shuffleEndpoint: NavigationEndpoint, + val radioEndpoint: NavigationEndpoint, + val thumbnails: List, + override val navigationEndpoint: NavigationEndpoint, +) : Item() { + companion object : FromContent { + override fun from(item: MusicResponsiveListItemRenderer): PlaylistItem = PlaylistItem( + title = item.getTitle(), + subtitle = item.getSubtitle(), + shuffleEndpoint = item.menu.getShuffleEndpoint()!!, + radioEndpoint = item.menu.getRadioEndpoint()!!, + thumbnails = item.thumbnail!!.getThumbnails(), + navigationEndpoint = item.navigationEndpoint!! + ) + + override fun from(item: MusicTwoRowItemRenderer): PlaylistItem = PlaylistItem( + title = item.title.toString(), + subtitle = item.subtitle.toString(), + shuffleEndpoint = item.menu.getShuffleEndpoint()!!, + radioEndpoint = item.menu.getRadioEndpoint()!!, + thumbnails = item.thumbnailRenderer.getThumbnails(), + navigationEndpoint = item.navigationEndpoint + ) + } +} + +@Serializable +data class ArtistItem( + override val title: String, + val subtitle: String, + val shuffleEndpoint: NavigationEndpoint, + val radioEndpoint: NavigationEndpoint, + val thumbnails: List, + override val navigationEndpoint: NavigationEndpoint, +) : Item() { + companion object : FromContent { + override fun from(item: MusicResponsiveListItemRenderer): ArtistItem = ArtistItem( + title = item.getTitle(), + subtitle = item.getSubtitle(), + shuffleEndpoint = item.menu.getShuffleEndpoint()!!, + radioEndpoint = item.menu.getRadioEndpoint()!!, + thumbnails = item.thumbnail!!.getThumbnails(), + navigationEndpoint = item.navigationEndpoint!! + ) + + override fun from(item: MusicTwoRowItemRenderer): ArtistItem { + return ArtistItem( + title = item.title.toString(), + subtitle = item.subtitle.toString(), + shuffleEndpoint = item.menu.getShuffleEndpoint()!!, + radioEndpoint = item.menu.getRadioEndpoint()!!, + thumbnails = item.thumbnailRenderer.getThumbnails(), + navigationEndpoint = item.navigationEndpoint + ) + } + } +} + +@Serializable +data class NavigationItem( + override val title: String, + val icon: String?, + val stripeColor: Long?, + override val navigationEndpoint: NavigationEndpoint, +) : Item() diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt new file mode 100644 index 000000000..df8da39bb --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt @@ -0,0 +1,9 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Locale( + val gl: String, + val hl: String, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt new file mode 100644 index 000000000..c18b54f40 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt @@ -0,0 +1,43 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.BrowseEndpoint +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class Menu( + val menuRenderer: MenuRenderer, +) { + fun getShuffleEndpoint(): NavigationEndpoint? = findEndpointByIcon(ICON_SHUFFLE) + fun getRadioEndpoint(): NavigationEndpoint? = findEndpointByIcon(ICON_MIX) + fun getAlbumEndpoint(): BrowseEndpoint? = findEndpointByIcon(ICON_ALBUM)?.browseEndpoint + fun getArtistEndpoint(): BrowseEndpoint? = findEndpointByIcon(ICON_ARTIST)?.browseEndpoint + + private fun findEndpointByIcon(iconType: String): NavigationEndpoint? = menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == iconType + }?.menuNavigationItemRenderer?.navigationEndpoint + + @Serializable + data class MenuRenderer( + val items: List, + ) { + @Serializable + data class Item( + val menuNavigationItemRenderer: MenuNavigationItemRenderer?, + ) { + @Serializable + data class MenuNavigationItemRenderer( + val text: Runs, + val icon: Icon, + val navigationEndpoint: NavigationEndpoint, + ) + } + } + + companion object { + const val ICON_SHUFFLE = "MUSIC_SHUFFLE" + const val ICON_MIX = "MIX" + const val ICON_ALBUM = "ALBUM" + const val ICON_ARTIST = "ARTIST" + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt new file mode 100644 index 000000000..16a28b5f1 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt @@ -0,0 +1,32 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicCarouselShelfRenderer( + val header: Header, + val contents: List, + val itemSize: String, +) { + @Serializable + data class Header( + val musicCarouselShelfBasicHeaderRenderer: MusicCarouselShelfBasicHeaderRenderer, + ) { + @Serializable + data class MusicCarouselShelfBasicHeaderRenderer( + val title: Runs, + val moreContentButton: Button?, + ) + } + + @Serializable + data class Content( + val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, + val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?, // navigation button in explore tab + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, + ) { + fun toItem() = musicTwoRowItemRenderer?.toItem() + ?: musicNavigationButtonRenderer?.toItem() + ?: musicResponsiveListItemRenderer?.toItem()!! + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt new file mode 100644 index 000000000..4f08e6185 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt @@ -0,0 +1,11 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicDescriptionShelfRenderer( + val header: Runs?, + val subheader: Runs?, + val description: Runs, + val footer: Runs?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt new file mode 100644 index 000000000..942b52ad4 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt @@ -0,0 +1,24 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class MusicNavigationButtonRenderer( + val buttonText: Runs, + val solid: Solid?, + val icon: Icon?, + val clickCommand: NavigationEndpoint, +) { + fun toItem() = NavigationItem( + title = buttonText.toString(), + icon = icon?.iconType, + stripeColor = solid?.leftStripeColor, + navigationEndpoint = clickCommand + ) + + @Serializable + data class Solid( + val leftStripeColor: Long, + ) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicPlaylistShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicPlaylistShelfRenderer.kt new file mode 100644 index 000000000..5d2955a3b --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicPlaylistShelfRenderer.kt @@ -0,0 +1,10 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicPlaylistShelfRenderer( + val playlistId: String, + val contents: List, + val collapsedItemCount: Int, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicQueueRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicQueueRenderer.kt new file mode 100644 index 000000000..b192b62a9 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicQueueRenderer.kt @@ -0,0 +1,13 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicQueueRenderer( + val content: Content?, +) { + @Serializable + data class Content( + val playlistPanelRenderer: PlaylistPanelRenderer, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt new file mode 100644 index 000000000..3a05f4c53 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +data class MusicResponsiveListItemRenderer( + val fixedColumns: List?, + val flexColumns: List, + val thumbnail: ThumbnailRenderer?, + val menu: Menu, + val playlistItemData: PlaylistItemData?, + val index: Runs?, + val navigationEndpoint: NavigationEndpoint?, +) { + fun getTitle() = flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.toString() + fun getSubtitle() = (flexColumns.drop(1) + fixedColumns.orEmpty()) + .filter { + it.musicResponsiveListItemFlexColumnRenderer.text.runs.isNotEmpty() + }.joinToString(separator = " • ") { + it.musicResponsiveListItemFlexColumnRenderer.text.toString() + } + + private val isSong: Boolean + get() = navigationEndpoint == null && thumbnail!!.isSquare + private val isVideo: Boolean + get() = navigationEndpoint == null && !thumbnail!!.isSquare + private val isPlaylist: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_PLAYLIST + private val isAlbum: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ALBUM + private val isArtist: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ARTIST + + fun toItem(): Item = when { + isSong -> SongItem.from(this) + isVideo -> VideoItem.from(this) + isPlaylist -> PlaylistItem.from(this) + isAlbum -> AlbumItem.from(this) + isArtist -> ArtistItem.from(this) + else -> throw UnsupportedOperationException("Unknown item type") + } + + @Serializable + data class FlexColumn( + @JsonNames("musicResponsiveListItemFixedColumnRenderer") + val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer, + ) { + @Serializable + data class MusicResponsiveListItemFlexColumnRenderer( + val text: Runs, + ) + } + + @Serializable + data class PlaylistItemData( + val playlistSetVideoId: String?, + val videoId: String, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt new file mode 100644 index 000000000..57e54c401 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt @@ -0,0 +1,25 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class MusicShelfRenderer( + val title: Runs?, + val contents: List, + val bottomEndpoint: NavigationEndpoint?, + val moreContentButton: MoreContentButton?, + val continuations: List?, +) { + @Serializable + data class Content( + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer, + ) { + fun toItem(): Item = musicResponsiveListItemRenderer.toItem() + } + + @Serializable + data class MoreContentButton( + val buttonRenderer: Button, + ) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt new file mode 100644 index 000000000..3a016fcd8 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt @@ -0,0 +1,35 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import com.zionhuang.innertube.models.endpoints.WatchEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class MusicTwoRowItemRenderer( + val title: Runs, + val subtitle: Runs, + val menu: Menu, + val thumbnailRenderer: ThumbnailRenderer, + val navigationEndpoint: NavigationEndpoint, + // val thumbnailOverlay: ThumbnailOverlay, (for playing the album directly) +) { + private val isSong: Boolean + get() = navigationEndpoint.endpoint is WatchEndpoint && thumbnailRenderer.isSquare + private val isVideo: Boolean + get() = navigationEndpoint.endpoint is WatchEndpoint && !thumbnailRenderer.isSquare + private val isPlaylist: Boolean + get() = navigationEndpoint.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_PLAYLIST + private val isAlbum: Boolean + get() = navigationEndpoint.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ALBUM + private val isArtist: Boolean + get() = navigationEndpoint.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ARTIST + + fun toItem(): Item = when { + isSong -> SongItem.from(this) + isVideo -> VideoItem.from(this) + isPlaylist -> PlaylistItem.from(this) + isAlbum -> AlbumItem.from(this) + isArtist -> ArtistItem.from(this) + else -> throw UnsupportedOperationException("Unknown item type") + } +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt b/innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt new file mode 100644 index 000000000..c80973b8a --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt @@ -0,0 +1,9 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class NextResult( + val items: List, + val continuation: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt new file mode 100644 index 000000000..f2761062c --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt @@ -0,0 +1,21 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PlaylistPanelRenderer( + val title: String, + val titleText: Runs, + val shortBylineText: Runs, + val contents: List, + val currentIndex: Int?, + val isInfinite: Boolean, + val numItemsToShow: Int, + val playlistId: String, + val continuations: List, +) { + @Serializable + data class Content( + val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer, + ) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt new file mode 100644 index 000000000..95326f25a --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt @@ -0,0 +1,27 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class PlaylistPanelVideoRenderer( + val title: Runs, + val lengthText: Runs, + val longBylineText: Runs, + val shortBylineText: Runs, + val videoId: String, + val playlistSetVideoId: String?, + val selected: Boolean, + val thumbnail: Thumbnails, + val menu: Menu, + val navigationEndpoint: NavigationEndpoint, +) { + fun toSongItem(): SongItem = SongItem( + title = title.toString(), + subtitle = longBylineText.toString(), + artistEndpoint = menu.getArtistEndpoint(), + albumEndpoint = menu.getAlbumEndpoint(), + thumbnails = thumbnail.thumbnails, + navigationEndpoint = navigationEndpoint + ) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistSongInfo.kt b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistSongInfo.kt new file mode 100644 index 000000000..63c526d82 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistSongInfo.kt @@ -0,0 +1,10 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.BrowseEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class PlaylistSongInfo( + val lyricsEndpoint: BrowseEndpoint, + val relatedEndpoint: BrowseEndpoint, +) \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt new file mode 100644 index 000000000..5f9d0304d --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt @@ -0,0 +1,40 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class Runs( + val runs: List = emptyList(), +) { + override fun toString() = runs.joinToString(separator = "") { it.text } +} + +@Serializable +data class Run( + val text: String, + val navigationEndpoint: NavigationEndpoint?, +) + +fun List.splitBySeparator(): List> { + val res = mutableListOf>() + var tmp = mutableListOf() + forEach { run -> + if (run.text == " • ") { + res.add(tmp) + tmp = mutableListOf() + } else { + tmp.add(run) + } + } + res.add(tmp) + return res +} + +fun List.removeSeparator(): List { + val res = mutableListOf() + for (i in 0..lastIndex step 2) { + res.add(get(i)) + } + return res +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/SearchAllTypeResult.kt b/innertube/src/main/java/com/zionhuang/innertube/models/SearchAllTypeResult.kt new file mode 100644 index 000000000..47fd448fc --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SearchAllTypeResult.kt @@ -0,0 +1,9 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchAllTypeResult( + val filters: List?, + val sections: List
, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/SearchResult.kt b/innertube/src/main/java/com/zionhuang/innertube/models/SearchResult.kt new file mode 100644 index 000000000..5ea62a0e4 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SearchResult.kt @@ -0,0 +1,9 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchResult( + val items: List, + val continuation: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt new file mode 100644 index 000000000..cc558047f --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt @@ -0,0 +1,41 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class SearchSuggestionsSectionRenderer( + val contents: List, +) { + @Serializable + data class Content( + val searchSuggestionRenderer: SearchSuggestionRenderer?, + val musicTwoColumnItemRenderer: MusicTwoColumnItemRenderer?, + ) { + fun toSuggestionItem() = when { + searchSuggestionRenderer != null -> Text(searchSuggestionRenderer.suggestion.toString()) + musicTwoColumnItemRenderer != null -> Navigation( + title = musicTwoColumnItemRenderer.title.toString(), + subtitle = musicTwoColumnItemRenderer.subtitle.toString(), + thumbnail = musicTwoColumnItemRenderer.thumbnail.musicThumbnailRenderer!!.thumbnail.thumbnails, + thumbnailCrop = musicTwoColumnItemRenderer.thumbnail.musicThumbnailRenderer.thumbnailCrop!!, + navigationEndpoint = musicTwoColumnItemRenderer.navigationEndpoint + ) + else -> throw UnsupportedOperationException("Unknown suggestion item type") + } + + @Serializable + data class SearchSuggestionRenderer( + val suggestion: Runs, + val navigationEndpoint: NavigationEndpoint, + ) + + @Serializable + data class MusicTwoColumnItemRenderer( + val title: Runs, + val subtitle: Runs, + val thumbnail: ThumbnailRenderer, + val navigationEndpoint: NavigationEndpoint, + ) + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Section.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Section.kt new file mode 100644 index 000000000..7eafecf52 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Section.kt @@ -0,0 +1,35 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.BrowseEndpoint +import com.zionhuang.innertube.models.endpoints.SearchEndpoint +import kotlinx.serialization.Serializable + +@Serializable +sealed class Section + +@Serializable +data class ItemSection( + val title: String? = null, + val items: List, + val continuation: String? = null, + val bottomEndpoint: SearchEndpoint? = null, +) : Section() + +@Serializable +data class DescriptionSection( + val title: String, + val subtitle: String, + val description: String, +) : Section() + +@Serializable +data class LinkSection( + val navigationButtons: List, +) : Section() + +@Serializable +data class GridSection( + val title: String, + val navigationButtons: List, + val moreNavigationEndpoint: BrowseEndpoint, +) : Section() \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt new file mode 100644 index 000000000..39826a19b --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt @@ -0,0 +1,80 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +data class SectionListRenderer( + val header: Header?, + val contents: List, + val continuations: List?, +) { + @Serializable + data class Header( + val chipCloudRenderer: ChipCloudRenderer, + ) { + @Serializable + data class ChipCloudRenderer( + val chips: List, + ) { + @Serializable + data class Chip( + val chipCloudChipRenderer: ChipCloudChipRenderer, + ) { + @Serializable + data class ChipCloudChipRenderer( + val isSelected: Boolean, + val navigationEndpoint: NavigationEndpoint, + // The close button doesn't have the following two fields + val text: Runs?, + val uniqueId: String?, + ) + + fun toFilter() = Filter( + text = chipCloudChipRenderer.text.toString(), + searchEndpoint = chipCloudChipRenderer.navigationEndpoint.searchEndpoint!! + ) + } + } + } + + @Serializable + data class Content( + @JsonNames("musicImmersiveCarouselShelfRenderer") + val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?, + val musicShelfRenderer: MusicShelfRenderer?, + val musicPlaylistShelfRenderer: MusicPlaylistShelfRenderer?, + val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, + val gridRenderer: GridRenderer?, + ) { + fun toSection(): Section? { + return when { + musicCarouselShelfRenderer != null -> ItemSection( + title = musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.toString(), + items = musicCarouselShelfRenderer.contents.map { it.toItem() } + ) + musicShelfRenderer != null -> toItemSection() + musicDescriptionShelfRenderer != null -> DescriptionSection( + title = musicDescriptionShelfRenderer.header.toString(), + subtitle = musicDescriptionShelfRenderer.subheader.toString(), + description = musicDescriptionShelfRenderer.description.toString() + ) + gridRenderer != null -> ItemSection( + items = gridRenderer.items.map { it.toItem() } + ) + else -> null + } + } + + fun toItemSection() = ItemSection( + title = musicShelfRenderer!!.title.toString(), + items = musicShelfRenderer.contents.map { it.toItem() }, + continuation = musicShelfRenderer.continuations?.getContinuation(), + bottomEndpoint = musicShelfRenderer.bottomEndpoint?.searchEndpoint + ) + } +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/SuggestionItem.kt b/innertube/src/main/java/com/zionhuang/innertube/models/SuggestionItem.kt new file mode 100644 index 000000000..7306ca4a8 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SuggestionItem.kt @@ -0,0 +1,21 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +sealed class SuggestionItem() + +@Serializable +data class Text( + val suggestion: String, +) : SuggestionItem() + +@Serializable +data class Navigation( + val title: String, + val subtitle: String, + val thumbnail: List, + val thumbnailCrop: String, + val navigationEndpoint: NavigationEndpoint, +) : SuggestionItem() \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Tabs.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Tabs.kt new file mode 100644 index 000000000..9e18f8617 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Tabs.kt @@ -0,0 +1,27 @@ +package com.zionhuang.innertube.models + +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class Tabs( + val tabs: List, +) { + @Serializable + data class Tab( + val tabRenderer: TabRenderer, + ) { + @Serializable + data class TabRenderer( + val title: String?, + val content: Content?, + val endpoint: NavigationEndpoint?, + ) { + @Serializable + data class Content( + val sectionListRenderer: SectionListRenderer?, + val musicQueueRenderer: MusicQueueRenderer?, + ) + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt new file mode 100644 index 000000000..b7479fe8b --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt @@ -0,0 +1,45 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.zionhuang.innertube.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +data class ThumbnailRenderer( + @JsonNames("croppedSquareThumbnailRenderer") + val musicThumbnailRenderer: MusicThumbnailRenderer?, + val musicAnimatedThumbnailRenderer: MusicAnimatedThumbnailRenderer?, +) { + val isSquare: Boolean + get() = when { + musicThumbnailRenderer != null -> musicThumbnailRenderer.thumbnail.thumbnails[0].isSquare + musicAnimatedThumbnailRenderer != null -> musicAnimatedThumbnailRenderer.backupRenderer.thumbnail.thumbnails[0].isSquare + else -> throw UnsupportedOperationException("Unknown thumbnail type") + } + + fun getThumbnails(): List = when { + musicThumbnailRenderer != null -> musicThumbnailRenderer.thumbnail.thumbnails + musicAnimatedThumbnailRenderer != null -> musicAnimatedThumbnailRenderer.backupRenderer.thumbnail.thumbnails + else -> throw UnsupportedOperationException("Unknown thumbnail type") + } + + @Serializable + data class MusicThumbnailRenderer( + val thumbnail: Thumbnails, + val thumbnailCrop: String?, + val thumbnailScale: String?, + ) { + companion object { + const val MUSIC_THUMBNAIL_CROP_UNSPECIFIED = "MUSIC_THUMBNAIL_CROP_UNSPECIFIED" + const val MUSIC_THUMBNAIL_SCALE_ASPECT_FIT = "MUSIC_THUMBNAIL_SCALE_ASPECT_FIT" + } + } + + @Serializable + data class MusicAnimatedThumbnailRenderer( + val animatedThumbnail: Thumbnails, + val backupRenderer: MusicThumbnailRenderer, + ) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt new file mode 100644 index 000000000..f30ad0002 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt @@ -0,0 +1,17 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Thumbnails( + val thumbnails: List, +) + +@Serializable +data class Thumbnail( + val url: String, + val width: Int, + val height: Int, +) { + val isSquare: Boolean get() = width == height +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt new file mode 100644 index 000000000..c839f1264 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -0,0 +1,50 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YouTubeClient( + val clientName: String, + val clientVersion: String, + val api_key: String, + val userAgent: String, + val referer: String? = null, +) { + fun toContext(locale: Locale) = Context( + client = Context.Client( + clientName = clientName, + clientVersion = clientVersion, + gl = locale.gl, + hl = locale.hl + ) + ) + + companion object { + private const val REFERER_YOUTUBE_MUSIC = "https://music.youtube.com/" + + private const val USER_AGENT_WEB = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36" + private const val USER_AGENT_ANDROID = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Mobile Safari/537.36" + + val ANDROID_MUSIC = YouTubeClient( + clientName = "ANDROID_MUSIC", + clientVersion = "5.01", + api_key = "AIzaSyAOghZGza2MQSZkY_zfZ370N-PUdXEo8AI", + userAgent = USER_AGENT_ANDROID + ) + + val ANDROID = YouTubeClient( + clientName = "ANDROID", + clientVersion = "17.13.3", + api_key = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", + userAgent = USER_AGENT_ANDROID, + ) + + val WEB_REMIX = YouTubeClient( + clientName = "WEB_REMIX", + clientVersion = "1.20220606.03.00", + api_key = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30", + userAgent = USER_AGENT_WEB, + referer = REFERER_YOUTUBE_MUSIC, + ) + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/BrowseBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/BrowseBody.kt new file mode 100644 index 000000000..f7fc5cc18 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/BrowseBody.kt @@ -0,0 +1,11 @@ +package com.zionhuang.innertube.models.body + +import com.zionhuang.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class BrowseBody( + val context: Context, + val browseId: String, + val params: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/GetQueueBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/GetQueueBody.kt new file mode 100644 index 000000000..d8daf46df --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/GetQueueBody.kt @@ -0,0 +1,11 @@ +package com.zionhuang.innertube.models.body + +import com.zionhuang.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class GetQueueBody( + val context: Context, + val videoIds: List?, + val playlistId: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/GetSearchSuggestionsBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/GetSearchSuggestionsBody.kt new file mode 100644 index 000000000..ea13ee431 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/GetSearchSuggestionsBody.kt @@ -0,0 +1,10 @@ +package com.zionhuang.innertube.models.body + +import com.zionhuang.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class GetSearchSuggestionsBody( + val context: Context, + val input: String, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/NextBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/NextBody.kt new file mode 100644 index 000000000..c426fad54 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/NextBody.kt @@ -0,0 +1,15 @@ +package com.zionhuang.innertube.models.body + +import com.zionhuang.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class NextBody( + val context: Context, + val videoId: String, + val playlistId: String?, + val playlistSetVideoId: String?, + val index: Int?, + val params: String?, + val continuation: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt new file mode 100644 index 000000000..89b282496 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt @@ -0,0 +1,11 @@ +package com.zionhuang.innertube.models.body + +import com.zionhuang.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerBody( + val context: Context, + val videoId: String, + val playlistId: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/SearchBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/SearchBody.kt new file mode 100644 index 000000000..ef0be3db1 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/SearchBody.kt @@ -0,0 +1,11 @@ +package com.zionhuang.innertube.models.body + +import com.zionhuang.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class SearchBody( + val context: Context, + val query: String?, + val params: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/endpoints/Endpoint.kt b/innertube/src/main/java/com/zionhuang/innertube/models/endpoints/Endpoint.kt new file mode 100644 index 000000000..c296b5276 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/endpoints/Endpoint.kt @@ -0,0 +1,55 @@ +package com.zionhuang.innertube.models.endpoints + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Endpoint + +@Serializable +data class WatchEndpoint( + val videoId: String, + val playlistId: String?, + val playlistSetVideoId: String?, + val params: String?, + val index: Int?, + val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null, +) : Endpoint() { + @Serializable + data class WatchEndpointMusicSupportedConfigs( + val watchEndpointMusicConfig: WatchEndpointMusicConfig, + ) { + @Serializable + data class WatchEndpointMusicConfig( + val musicVideoType: String, + ) + } +} + +@Serializable +data class BrowseEndpoint( + val browseId: String, + val params: String?, + val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?, +) : Endpoint() { + @Serializable + data class BrowseEndpointContextSupportedConfigs( + val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig, + ) { + @Serializable + data class BrowseEndpointContextMusicConfig( + val pageType: String, + ) + } +} + +@Serializable +data class WatchPlaylistEndpoint( + val params: String?, + val playlistId: String, +) : Endpoint() + +@Serializable +data class SearchEndpoint( + val params: String?, + val query: String, +) : Endpoint() diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/endpoints/NavigationEndpoint.kt b/innertube/src/main/java/com/zionhuang/innertube/models/endpoints/NavigationEndpoint.kt new file mode 100644 index 000000000..1f99cb5b4 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/endpoints/NavigationEndpoint.kt @@ -0,0 +1,14 @@ +package com.zionhuang.innertube.models.endpoints + +import kotlinx.serialization.Serializable + +@Serializable +data class NavigationEndpoint( + val watchEndpoint: WatchEndpoint?, + val browseEndpoint: BrowseEndpoint?, + val watchPlaylistEndpoint: WatchPlaylistEndpoint?, + val searchEndpoint: SearchEndpoint?, +) { + val endpoint: Endpoint? + get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt new file mode 100644 index 000000000..8ae8e5a33 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt @@ -0,0 +1,61 @@ +package com.zionhuang.innertube.models.response + +import com.zionhuang.innertube.models.* +import kotlinx.serialization.Serializable + +@Serializable +data class BrowseResponse( + val contents: Contents, + val header: Header?, + val microformat: Microformat?, +) { + fun toSectionList() = contents.singleColumnBrowseResultsRenderer!!.tabs[0].tabRenderer.content!!.sectionListRenderer!!.contents.mapNotNull { + it.toSection() + } + + fun toBrowseResult() = BrowseResult( + sections = toSectionList(), + continuation = contents.singleColumnBrowseResultsRenderer!!.tabs[0].tabRenderer.content!!.sectionListRenderer!!.continuations?.getContinuation() + ) + + @Serializable + data class Contents( + val singleColumnBrowseResultsRenderer: Tabs?, + val sectionListRenderer: SectionListRenderer?, + ) + + @Serializable + data class Header( + val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?, + val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?, + ) { + @Serializable + data class MusicImmersiveHeaderRenderer( + val title: Runs, + val description: Runs, + val thumbnail: ThumbnailRenderer, + val playButton: Button, + val startRadioButton: Button, + ) + + @Serializable + data class MusicDetailHeaderRenderer( + val title: Runs, + val subtitle: Runs, + val secondSubtitle: Runs, + val description: Runs, + val thumbnail: ThumbnailRenderer, + val menu: Menu, + ) + } + + @Serializable + data class Microformat( + val microformatDataRenderer: MicroformatDataRenderer, + ) { + @Serializable + data class MicroformatDataRenderer( + val urlCanonical: String, + ) + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/GetQueueResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/GetQueueResponse.kt new file mode 100644 index 000000000..fd326a3c5 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/GetQueueResponse.kt @@ -0,0 +1,14 @@ +package com.zionhuang.innertube.models.response + +import com.zionhuang.innertube.models.PlaylistPanelRenderer +import kotlinx.serialization.Serializable + +@Serializable +data class GetQueueResponse( + val queueDatas: List, +) { + @Serializable + data class QueueData( + val content: PlaylistPanelRenderer.Content, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/GetSearchSuggestionsResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/GetSearchSuggestionsResponse.kt new file mode 100644 index 000000000..1e919972d --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/GetSearchSuggestionsResponse.kt @@ -0,0 +1,14 @@ +package com.zionhuang.innertube.models.response + +import com.zionhuang.innertube.models.SearchSuggestionsSectionRenderer +import kotlinx.serialization.Serializable + +@Serializable +data class GetSearchSuggestionsResponse( + val contents: List, +) { + @Serializable + data class Content( + val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/NextResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/NextResponse.kt new file mode 100644 index 000000000..b70fd2321 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/NextResponse.kt @@ -0,0 +1,38 @@ +package com.zionhuang.innertube.models.response + +import com.zionhuang.innertube.models.PlaylistPanelRenderer +import com.zionhuang.innertube.models.Tabs +import com.zionhuang.innertube.models.endpoints.NavigationEndpoint +import kotlinx.serialization.Serializable + +@Serializable +data class NextResponse( + val contents: Contents, + val continuationContents: ContinuationContents?, + val currentVideoEndpoint: NavigationEndpoint?, +) { + @Serializable + data class Contents( + val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer, + ) { + @Serializable + data class SingleColumnMusicWatchNextResultsRenderer( + val tabbedRenderer: TabbedRenderer, + ) { + @Serializable + data class TabbedRenderer( + val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer, + ) { + @Serializable + data class WatchNextTabbedResultsRenderer( + val tabs: List, + ) + } + } + } + + @Serializable + data class ContinuationContents( + val playlistPanelContinuation: PlaylistPanelRenderer, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt new file mode 100644 index 000000000..d1ed29622 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt @@ -0,0 +1,74 @@ +package com.zionhuang.innertube.models.response + +import com.zionhuang.innertube.models.Thumbnails +import kotlinx.serialization.Serializable + +/** + * PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.ANDROID_MUSIC] client + */ +@Serializable +data class PlayerResponse( + val responseContext: ResponseContext, + val playabilityStatus: PlayabilityStatus, + val playerConfig: PlayerConfig?, + val streamingData: StreamingData?, + val videoDetails: VideoDetails, +) { + @Serializable + data class ResponseContext( + val visitorData: String, + ) + + @Serializable + data class PlayabilityStatus( + val status: String, + val reason: String?, + ) + + @Serializable + data class PlayerConfig( + val audioConfig: AudioConfig, + ) { + @Serializable + data class AudioConfig( + val loudnessDb: Double, + val perceptualLoudnessDb: Double, + ) + } + + @Serializable + data class StreamingData( + val formats: List, + val adaptiveFormats: List, + val expiresInSeconds: String, + ) { + @Serializable + data class Format( + val itag: Int, + val url: String, + val mimeType: String, + val bitrate: Int, + val width: Int?, + val height: Int?, + val contentLength: Long?, + val quality: String, + val fps: Int?, + val qualityLabel: String?, + val averageBitrate: Int?, + val audioQuality: String?, + val approxDurationMs: Long, + val audioSampleRate: Int?, + val audioChannels: Int?, + ) + } + + @Serializable + data class VideoDetails( + val videoId: String, + val title: String, + val author: String, + val lengthSeconds: String, + val channelId: String, + val thumbnail: Thumbnails, + ) +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt new file mode 100644 index 000000000..bb0baebf8 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt @@ -0,0 +1,36 @@ +package com.zionhuang.innertube.models.response + +import com.zionhuang.innertube.models.Continuation +import com.zionhuang.innertube.models.Item +import com.zionhuang.innertube.models.MusicResponsiveListItemRenderer +import com.zionhuang.innertube.models.Tabs +import kotlinx.serialization.Serializable + +@Serializable +data class SearchResponse( + val contents: Contents?, + val continuationContents: ContinuationContents?, +) { + @Serializable + data class Contents( + val tabbedSearchResultsRenderer: Tabs, + ) + + @Serializable + data class ContinuationContents( + val musicShelfContinuation: MusicShelfContinuation, + ) { + @Serializable + data class MusicShelfContinuation( + val contents: List, + val continuations: List?, + ) { + @Serializable + data class Content( + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer, + ) { + fun toItem(): Item = musicResponsiveListItemRenderer.toItem() + } + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt new file mode 100644 index 000000000..ec937ac2f --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt @@ -0,0 +1,14 @@ +package com.zionhuang.innertube.utils + +object TimeParser { + fun parse(text: String): Int { + val parts = text.split(":").map { it.toInt() } + if (parts.size == 2) { + return parts[0] * 60 + parts[1] + } + if (parts.size == 3) { + return parts[0] * 1440 + parts[1] * 60 + parts[2] + } + throw IllegalArgumentException("Unknown time format") + } +} \ No newline at end of file diff --git a/innertube/src/test/java/YouTubeTest.kt b/innertube/src/test/java/YouTubeTest.kt new file mode 100644 index 000000000..45ee32359 --- /dev/null +++ b/innertube/src/test/java/YouTubeTest.kt @@ -0,0 +1,144 @@ +import com.zionhuang.innertube.InnerTube.Companion.ALBUM_PARAM +import com.zionhuang.innertube.InnerTube.Companion.ARTIST_PARAM +import com.zionhuang.innertube.InnerTube.Companion.COMMUNITY_PLAYLIST_PARAM +import com.zionhuang.innertube.InnerTube.Companion.FEATURED_PLAYLIST_PARAM +import com.zionhuang.innertube.InnerTube.Companion.SONG_PARAM +import com.zionhuang.innertube.InnerTube.Companion.VIDEO_PARAM +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.Locale +import com.zionhuang.innertube.models.toAlbumInfo +import com.zionhuang.innertube.models.toArtistInfo +import com.zionhuang.innertube.models.toPlaylistInfo +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class YouTubeTest { + private val youTube = YouTube(locale = Locale("US", "en")) + + @Test + fun `Check 'player' endpoint`() = VIDEO_IDS.forEach { videoId -> + runBlocking { + val playerResponse = youTube.player(videoId) + assertEquals(videoId, playerResponse.videoDetails.videoId) + } + } + + @Test + fun `Check playable stream`() = VIDEO_IDS.forEach { videoId -> + runBlocking { + val playerResponse = youTube.player(videoId) + val format = playerResponse.streamingData!!.adaptiveFormats[0] + val url = format.url + println(url) + val response = HttpClient(CIO).get(url) { + headers { + append("Range", "bytes=0-10") + } + } + assertTrue(response.status.isSuccess()) + } + } + + @Test + fun `Check 'search' endpoint`() = runBlocking { + val searchAllTypeResult = youTube.searchAllType(SEARCH_QUERY) + assertTrue(searchAllTypeResult.sections.size > 1) + for (params in listOf( + SONG_PARAM, + FEATURED_PLAYLIST_PARAM, + VIDEO_PARAM, + ALBUM_PARAM, + COMMUNITY_PLAYLIST_PARAM, + ARTIST_PARAM + )) { + val searchResult = youTube.search(SEARCH_QUERY, params = params) + assertTrue(searchResult.items.isNotEmpty()) + } + } + + @Test + fun `Check search continuation`() = runBlocking { + var searchResult = youTube.search(SEARCH_QUERY, params = SONG_PARAM) + while (searchResult.continuation != null) { + searchResult.items.forEach { + println(it.title) + } + searchResult = youTube.search(searchResult.continuation!!) + } + searchResult.items.forEach { + println(it.title) + } + } + + @Test + fun `Check 'get_search_suggestion' endpoint`() = runBlocking { + val suggestions = youTube.getSearchSuggestions(SEARCH_QUERY) + assertTrue(suggestions.isNotEmpty()) + } + + @Test + fun `Check 'browse' endpoint`() = runBlocking { + val artistInfo = youTube.browse("UCI6B8NkZKqlFWoiC_xE-hzA").toArtistInfo() + assertTrue(artistInfo.contents.isNotEmpty()) + val albumInfo = youTube.browse("MPREb_oNAdr9eUOfS").toAlbumInfo() + assertTrue(albumInfo.items.isNotEmpty()) + val playlistInfo = youTube.browse("VLRDCLAK5uy_mHAEb33pqvgdtuxsemicZNu-5w6rLRweo").toPlaylistInfo() + assertTrue(playlistInfo.subtitle.isNotEmpty()) + listOf("FEmusic_home", "FEmusic_explore").forEach { browseId -> + val result = youTube.browse(browseId).toBrowseResult() + assertTrue(result.sections.isNotEmpty()) + } + } + + @Test + fun `Check 'next' endpoint`() = runBlocking { + val videoId = "qivRUhepWVA" + val playlistId = "RDEMQWAKLFUHzBCn9nEsPHDYAw" + val nextResult = youTube.getPlaylistItems(videoId = videoId, playlistId = playlistId) + assertTrue(nextResult.items.isNotEmpty()) + val playlistSongInfo = youTube.getPlaylistSongInfo(videoId = VIDEO_IDS.random()) + } + + @Test + fun `Check 'next' continuation`() = runBlocking { + val videoId = "qivRUhepWVA" + val playlistId = "RDEMQWAKLFUHzBCn9nEsPHDYAw" + var count = 5 + var nextResult = youTube.getPlaylistItems(videoId = videoId, playlistId = playlistId) + while (nextResult.continuation != null && count > 0) { + nextResult.items.forEach { + println(it.title) + } + nextResult = youTube.getPlaylistItems(videoId = videoId, playlistId = playlistId, continuation = nextResult.continuation) + count -= 1 + } + nextResult.items.forEach { + println(it.title) + } + } + + @Test + fun `Check 'get_queue' endpoint`() = runBlocking { + var queue = youTube.getQueue(videoIds = VIDEO_IDS) + assertTrue(queue[0].navigationEndpoint.watchEndpoint!!.videoId == VIDEO_IDS[0]) + queue = youTube.getQueue(playlistId = PLAYLIST_ID) + assertTrue(queue.isNotEmpty()) + } + + companion object { + private val VIDEO_IDS = listOf( + "4H-N260cPCg", +// "x8VYWazR5mE" Login required + ) + + private const val PLAYLIST_ID = "RDAMVM_WVXrDmm-P0" + + private const val SEARCH_QUERY = "YOASOBI" + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f88f91a57..d59de0b4a 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,3 @@ -include(":app") rootProject.name = "Music" +include(":app") +include(":innertube") From 467c08d4a3fceb05c3ced6f8afd7aa54bdb648f8 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 25 Jun 2022 23:25:17 +0800 Subject: [PATCH 002/189] Add InnerTube initialization --- app/src/main/java/com/zionhuang/music/App.kt | 10 ++++++++++ .../main/java/com/zionhuang/innertube/InnerTube.kt | 12 ++++++++---- .../src/main/java/com/zionhuang/innertube/YouTube.kt | 10 ++++++++-- .../java/com/zionhuang/innertube/models/Locale.kt | 9 --------- .../com/zionhuang/innertube/models/YouTubeClient.kt | 2 +- .../com/zionhuang/innertube/models/YouTubeLocale.kt | 9 +++++++++ .../innertube/models/response/PlayerResponse.kt | 5 ++++- innertube/src/test/java/YouTubeTest.kt | 4 ++-- 8 files changed, 42 insertions(+), 19 deletions(-) delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index eb083f38b..2cf379b3b 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -1,10 +1,14 @@ package com.zionhuang.music import android.app.Application +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.YouTubeLocale +import com.zionhuang.music.extensions.sharedPreferences import com.zionhuang.music.utils.getPreferredContentCountry import com.zionhuang.music.utils.getPreferredLocalization import com.zionhuang.music.youtube.NewPipeDownloader import org.schabi.newpipe.extractor.NewPipe +import java.util.* class App : Application() { override fun onCreate() { @@ -15,6 +19,12 @@ class App : Application() { getPreferredLocalization(this), getPreferredContentCountry(this) ) + + val systemDefault = getString(R.string.default_localization_key) + YouTube.locale = YouTubeLocale( + gl = sharedPreferences.getString(getString(R.string.pref_content_country), systemDefault).takeIf { it != systemDefault } ?: Locale.getDefault().country, + hl = sharedPreferences.getString(getString(R.string.pref_content_language), systemDefault).takeIf { it != systemDefault } ?: Locale.getDefault().toLanguageTag() + ) } companion object { diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index a01928baa..398fd5388 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -1,8 +1,8 @@ package com.zionhuang.innertube import com.zionhuang.innertube.encoder.brotli -import com.zionhuang.innertube.models.Locale import com.zionhuang.innertube.models.YouTubeClient +import com.zionhuang.innertube.models.YouTubeLocale import com.zionhuang.innertube.models.body.* import io.ktor.client.* import io.ktor.client.engine.cio.* @@ -14,14 +14,18 @@ import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import java.util.* /** * Provide access to InnerTube endpoints. * For making HTTP requests, not parsing response. */ -class InnerTube( - private val locale: Locale, -) { +class InnerTube { + var locale = YouTubeLocale( + gl = Locale.getDefault().country, + hl = Locale.getDefault().toLanguageTag() + ) + @OptIn(ExperimentalSerializationApi::class) val httpClient = HttpClient(CIO) { expectSuccess = true diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 1eb235534..c4d442ee8 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -9,8 +9,14 @@ import io.ktor.client.call.* /** * Parse useful data with [InnerTube] sending requests. */ -class YouTube(locale: Locale) { - private val innerTube = InnerTube(locale) +object YouTube { + private val innerTube = InnerTube() + + var locale: YouTubeLocale + get() = innerTube.locale + set(value) { + innerTube.locale = value + } suspend fun getSearchSuggestions(query: String): List = innerTube.getSearchSuggestions(ANDROID_MUSIC, query).body() diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt deleted file mode 100644 index df8da39bb..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Locale.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zionhuang.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Locale( - val gl: String, - val hl: String, -) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index c839f1264..412c34be3 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -10,7 +10,7 @@ data class YouTubeClient( val userAgent: String, val referer: String? = null, ) { - fun toContext(locale: Locale) = Context( + fun toContext(locale: YouTubeLocale) = Context( client = Context.Client( clientName = clientName, clientVersion = clientVersion, diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt new file mode 100644 index 000000000..a4f6cf089 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt @@ -0,0 +1,9 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YouTubeLocale( + val gl: String, // geolocation + val hl: String, // host language +) \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt index d1ed29622..4c9842550 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt @@ -59,7 +59,10 @@ data class PlayerResponse( val approxDurationMs: Long, val audioSampleRate: Int?, val audioChannels: Int?, - ) + ) { + val isAudio: Boolean + get() = width == null + } } @Serializable diff --git a/innertube/src/test/java/YouTubeTest.kt b/innertube/src/test/java/YouTubeTest.kt index 45ee32359..c5b742c55 100644 --- a/innertube/src/test/java/YouTubeTest.kt +++ b/innertube/src/test/java/YouTubeTest.kt @@ -1,3 +1,4 @@ + import com.zionhuang.innertube.InnerTube.Companion.ALBUM_PARAM import com.zionhuang.innertube.InnerTube.Companion.ARTIST_PARAM import com.zionhuang.innertube.InnerTube.Companion.COMMUNITY_PLAYLIST_PARAM @@ -5,7 +6,6 @@ import com.zionhuang.innertube.InnerTube.Companion.FEATURED_PLAYLIST_PARAM import com.zionhuang.innertube.InnerTube.Companion.SONG_PARAM import com.zionhuang.innertube.InnerTube.Companion.VIDEO_PARAM import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.Locale import com.zionhuang.innertube.models.toAlbumInfo import com.zionhuang.innertube.models.toArtistInfo import com.zionhuang.innertube.models.toPlaylistInfo @@ -19,7 +19,7 @@ import org.junit.Assert.assertTrue import org.junit.Test class YouTubeTest { - private val youTube = YouTube(locale = Locale("US", "en")) + private val youTube = YouTube @Test fun `Check 'player' endpoint`() = VIDEO_IDS.forEach { videoId -> From c0acdcb3f5da4e14d4b44eb3b0ec5b9a6669b9a6 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 25 Jun 2022 23:31:31 +0800 Subject: [PATCH 003/189] Add temp get stream code --- .../zionhuang/music/playback/SongPlayer.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 454183f1e..a8f0133b3 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -13,7 +13,6 @@ import android.os.ResultReceiver import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat.* -import android.util.Log import android.util.Pair import androidx.core.content.getSystemService import androidx.core.net.toUri @@ -88,11 +87,25 @@ class SongPlayer( DefaultDataSource.Factory(context) ) { dataSpec -> runBlocking { - // TODO Error handling val mediaId = dataSpec.uri.host ?: throw IllegalArgumentException("Cannot find media id from uri host") if (localRepository.getSongById(mediaId)?.downloadState == STATE_DOWNLOADED) { return@runBlocking dataSpec.withUri(localRepository.getSongFile(mediaId).toUri()) } +// val uri = kotlin.runCatching { +// runBlocking(Dispatchers.IO) { +// YouTube.player(mediaId) +// } +// }.mapCatching { playerResponse -> +// if (playerResponse.playabilityStatus.status != "OK") { +// throw PlaybackException(playerResponse.playabilityStatus.status, null, ERROR_CODE_REMOTE_ERROR) +// } +// playerResponse.streamingData?.adaptiveFormats +// ?.filter { it.isAudio } +// ?.maxByOrNull { it.bitrate } +// ?.url +// ?.toUri() +// ?: throw PlaybackException("No stream available", null, ERROR_CODE_NO_STREAM) +// }.getOrThrow() val streamInfo = logTimeMillis(TAG, "Extractor duration: %d") { runBlocking { remoteRepository.getStream(mediaId) @@ -353,5 +366,7 @@ class SongPlayer( const val TAG = "SongPlayer" const val CHANNEL_ID = "music_channel_01" const val NOTIFICATION_ID = 888 + + const val ERROR_CODE_NO_STREAM = 1000001 } } \ No newline at end of file From b5f07920ee41fca10a43cb6c20a2a470a791db79 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 26 Jun 2022 13:25:01 +0800 Subject: [PATCH 004/189] Rename YouTubeRepository to NewPipeRepository --- .../java/com/zionhuang/music/playback/SongPlayer.kt | 4 ++-- .../{YouTubeRepository.kt => NewPipeRepository.kt} | 2 +- .../java/com/zionhuang/music/repos/SongRepository.kt | 2 +- .../zionhuang/music/ui/fragments/ExploreFragment.kt | 10 ++++++++++ .../com/zionhuang/music/viewmodels/ExploreViewModel.kt | 5 +---- .../com/zionhuang/music/viewmodels/SearchViewModel.kt | 4 ++-- .../zionhuang/music/viewmodels/SuggestionViewModel.kt | 4 ++-- .../music/viewmodels/YouTubeChannelViewModel.kt | 4 ++-- .../music/viewmodels/YouTubePlaylistViewModel.kt | 4 ++-- 9 files changed, 23 insertions(+), 16 deletions(-) rename app/src/main/java/com/zionhuang/music/repos/{YouTubeRepository.kt => NewPipeRepository.kt} (98%) diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index a8f0133b3..09ad325db 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -51,7 +51,7 @@ import com.zionhuang.music.models.toMediaDescription import com.zionhuang.music.playback.queues.EmptyQueue import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.repos.NewPipeRepository import com.zionhuang.music.repos.base.LocalRepository import com.zionhuang.music.repos.base.RemoteRepository import com.zionhuang.music.ui.activities.MainActivity @@ -73,7 +73,7 @@ class SongPlayer( notificationListener: PlayerNotificationManager.NotificationListener, ) : Player.Listener { private val localRepository: LocalRepository = SongRepository - private val remoteRepository: RemoteRepository = YouTubeRepository + private val remoteRepository: RemoteRepository = NewPipeRepository private var currentQueue: Queue = EmptyQueue() private val _mediaSession = MediaSessionCompat(context, context.getString(R.string.app_name)).apply { diff --git a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt b/app/src/main/java/com/zionhuang/music/repos/NewPipeRepository.kt similarity index 98% rename from app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt rename to app/src/main/java/com/zionhuang/music/repos/NewPipeRepository.kt index c7e890f2f..1bbd56632 100644 --- a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/NewPipeRepository.kt @@ -9,7 +9,7 @@ import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.Page import org.schabi.newpipe.extractor.stream.StreamInfo -object YouTubeRepository : RemoteRepository { +object NewPipeRepository : RemoteRepository { override fun search(query: String, filter: String): PagingSource = object : PagingSource() { @Suppress("BlockingMethodInNonBlockingContext") override suspend fun load(params: LoadParams) = try { diff --git a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt index 813c2936e..a3b294b6b 100644 --- a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt @@ -41,7 +41,7 @@ object SongRepository : LocalRepository { private val artistDao: ArtistDao = musicDatabase.artistDao private val playlistDao: PlaylistDao = musicDatabase.playlistDao private val downloadDao: DownloadDao = musicDatabase.downloadDao - private val remoteRepository: RemoteRepository = YouTubeRepository + private val remoteRepository: RemoteRepository = NewPipeRepository private var autoDownload by context.preference(R.string.pref_auto_download, false) diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt index 53f267267..02d98e3e6 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt @@ -4,21 +4,31 @@ import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.View +import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.google.android.material.transition.MaterialFadeThrough import com.zionhuang.music.R import com.zionhuang.music.databinding.FragmentExploreBinding import com.zionhuang.music.ui.fragments.base.BindingFragment +import com.zionhuang.music.viewmodels.ExploreViewModel class ExploreFragment : BindingFragment() { override fun getViewBinding() = FragmentExploreBinding.inflate(layoutInflater) + private val exploreViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + + } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.search_and_settings, menu) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt index 05b1988a6..ca92efba7 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt @@ -4,8 +4,5 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel class ExploreViewModel(application: Application) : AndroidViewModel(application) { -// private val youTubeRepo: YouTubeRepository = getInstance(application) -// val flow = Pager(PagingConfig(pageSize = 20)) { -// YouTubeDataSource.Popular(youTubeRepo) -// }.flow.cachedIn(viewModelScope) + } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt index f699a40ec..03b0fc6a9 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn -import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.repos.NewPipeRepository import com.zionhuang.music.utils.livedata.SafeMutableLiveData import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory.MUSIC_SONGS @@ -14,6 +14,6 @@ class SearchViewModel(application: Application) : AndroidViewModel(application) var searchFilter = SafeMutableLiveData(MUSIC_SONGS) fun search(query: String) = Pager(PagingConfig(pageSize = 20)) { - YouTubeRepository.search(query, searchFilter.value) + NewPipeRepository.search(query, searchFilter.value) }.flow.cachedIn(viewModelScope) } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt index cf8c61515..9d85c403f 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt @@ -4,7 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.repos.NewPipeRepository import kotlinx.coroutines.launch class SuggestionViewModel(application: Application) : AndroidViewModel(application) { @@ -27,7 +27,7 @@ class SuggestionViewModel(application: Application) : AndroidViewModel(applicati } viewModelScope.launch { try { - suggestions.postValue(YouTubeRepository.suggestionsFor(query)) + suggestions.postValue(NewPipeRepository.suggestionsFor(query)) } catch (e: Exception) { } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt index 86fa0870e..9b2496ca1 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt @@ -6,13 +6,13 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn -import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.repos.NewPipeRepository import com.zionhuang.music.youtube.NewPipeYouTubeHelper class YouTubeChannelViewModel(application: Application) : AndroidViewModel(application) { suspend fun getChannelInfo(channelId: String) = NewPipeYouTubeHelper.getChannel(channelId) fun getChannel(channelId: String) = Pager(PagingConfig(pageSize = 20)) { - YouTubeRepository.getChannel(channelId) + NewPipeRepository.getChannel(channelId) }.flow.cachedIn(viewModelScope) } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt index c9442ae42..79b415d70 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt @@ -6,13 +6,13 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn -import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.repos.NewPipeRepository import com.zionhuang.music.youtube.NewPipeYouTubeHelper class YouTubePlaylistViewModel(application: Application) : AndroidViewModel(application) { suspend fun getPlaylistInfo(playlistId: String) = NewPipeYouTubeHelper.getPlaylist(playlistId) fun getPlaylist(playlistId: String) = Pager(PagingConfig(pageSize = 20)) { - YouTubeRepository.getPlaylist(playlistId) + NewPipeRepository.getPlaylist(playlistId) }.flow.cachedIn(viewModelScope) } \ No newline at end of file From b699249c1349ff960be7a804dd504cb4ad60aaad Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 26 Jun 2022 13:34:37 +0800 Subject: [PATCH 005/189] Add SearchFilter value class --- .../java/com/zionhuang/innertube/YouTube.kt | 16 ++++++++-- innertube/src/test/java/YouTubeTest.kt | 30 +++++++++---------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index c4d442ee8..d778a6876 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -35,8 +35,8 @@ object YouTube { ) } - suspend fun search(query: String, params: String): SearchResult { - val response = innerTube.search(WEB_REMIX, query, params).body() + suspend fun search(query: String, filter: SearchFilter): SearchResult { + val response = innerTube.search(WEB_REMIX, query, filter.value).body() return SearchResult( items = response.contents!!.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content!!.sectionListRenderer!!.contents[0].musicShelfRenderer!!.contents .map { it.toItem() }, @@ -112,4 +112,16 @@ object YouTube { it.content.playlistPanelVideoRenderer.toSongItem() } } + + @JvmInline + value class SearchFilter(val value: String) { + companion object { + val FILTER_SONG = SearchFilter("EgWKAQIIAWoMEAMQDhAEEAkQChAF") + val FILTER_VIDEO = SearchFilter("EgWKAQIQAWoMEAMQDhAEEAkQChAF") + val FILTER_ALBUM = SearchFilter("EgWKAQIYAWoMEAMQDhAEEAkQChAF") + val FILTER_ARTIST = SearchFilter("EgWKAQIgAWoMEAMQDhAEEAkQChAF") + val FILTER_FEATURED_PLAYLIST = SearchFilter("EgeKAQQoADgBagwQAxAOEAQQCRAKEAU%3D") + val FILTER_COMMUNITY_PLAYLIST = SearchFilter("EgeKAQQoAEABagwQAxAOEAQQCRAKEAU%3D") + } + } } \ No newline at end of file diff --git a/innertube/src/test/java/YouTubeTest.kt b/innertube/src/test/java/YouTubeTest.kt index c5b742c55..03f451491 100644 --- a/innertube/src/test/java/YouTubeTest.kt +++ b/innertube/src/test/java/YouTubeTest.kt @@ -1,11 +1,11 @@ -import com.zionhuang.innertube.InnerTube.Companion.ALBUM_PARAM -import com.zionhuang.innertube.InnerTube.Companion.ARTIST_PARAM -import com.zionhuang.innertube.InnerTube.Companion.COMMUNITY_PLAYLIST_PARAM -import com.zionhuang.innertube.InnerTube.Companion.FEATURED_PLAYLIST_PARAM -import com.zionhuang.innertube.InnerTube.Companion.SONG_PARAM -import com.zionhuang.innertube.InnerTube.Companion.VIDEO_PARAM import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO import com.zionhuang.innertube.models.toAlbumInfo import com.zionhuang.innertube.models.toArtistInfo import com.zionhuang.innertube.models.toPlaylistInfo @@ -49,22 +49,22 @@ class YouTubeTest { fun `Check 'search' endpoint`() = runBlocking { val searchAllTypeResult = youTube.searchAllType(SEARCH_QUERY) assertTrue(searchAllTypeResult.sections.size > 1) - for (params in listOf( - SONG_PARAM, - FEATURED_PLAYLIST_PARAM, - VIDEO_PARAM, - ALBUM_PARAM, - COMMUNITY_PLAYLIST_PARAM, - ARTIST_PARAM + for (filter in listOf( + FILTER_SONG, + FILTER_FEATURED_PLAYLIST, + FILTER_VIDEO, + FILTER_ALBUM, + FILTER_COMMUNITY_PLAYLIST, + FILTER_ARTIST )) { - val searchResult = youTube.search(SEARCH_QUERY, params = params) + val searchResult = youTube.search(SEARCH_QUERY, filter) assertTrue(searchResult.items.isNotEmpty()) } } @Test fun `Check search continuation`() = runBlocking { - var searchResult = youTube.search(SEARCH_QUERY, params = SONG_PARAM) + var searchResult = youTube.search(SEARCH_QUERY, FILTER_SONG) while (searchResult.continuation != null) { searchResult.items.forEach { println(it.title) From b4a299128273da5b26e0ddc91afad6ed50a84e87 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 28 Jun 2022 12:39:56 +0800 Subject: [PATCH 006/189] Show data in explore fragment --- .../zionhuang/music/extensions/YouTubeExt.kt | 14 +++ .../music/repos/YouTubeRepository.kt | 43 +++++++ .../music/ui/adapters/SectionAdapter.kt | 48 ++++++++ .../music/ui/adapters/YouTubeItemAdapter.kt | 44 +++++++ .../zionhuang/music/ui/bindings/Bindings.kt | 16 ++- .../music/ui/fragments/ExploreFragment.kt | 24 +++- .../music/ui/viewholders/SectionViewHolder.kt | 50 ++++++++ .../ui/viewholders/YouTubeItemViewHolder.kt | 30 +++++ .../music/viewmodels/ExploreViewModel.kt | 9 +- app/src/main/res/layout/item_section.xml | 63 ++++++++++ app/src/main/res/layout/item_youtube_list.xml | 115 ++++++++++++++++++ .../res/layout/item_youtube_navigation.xml | 22 ++++ .../main/res/layout/item_youtube_square.xml | 51 ++++++++ .../java/com/zionhuang/innertube/InnerTube.kt | 23 ++-- .../java/com/zionhuang/innertube/YouTube.kt | 16 ++- .../com/zionhuang/innertube/models/Context.kt | 1 + .../innertube/models/GridRenderer.kt | 11 ++ .../com/zionhuang/innertube/models/Item.kt | 62 +++++----- .../models/MusicCarouselShelfRenderer.kt | 2 + .../com/zionhuang/innertube/models/Section.kt | 37 +++--- .../innertube/models/SectionListRenderer.kt | 43 ++++--- .../innertube/models/SuggestionItem.kt | 2 +- .../innertube/models/YouTubeClient.kt | 5 +- .../innertube/models/body/BrowseBody.kt | 2 +- .../models/response/BrowseResponse.kt | 27 +++- innertube/src/test/java/YouTubeTest.kt | 23 +++- 26 files changed, 681 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/adapters/SectionAdapter.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/viewholders/SectionViewHolder.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeItemViewHolder.kt create mode 100644 app/src/main/res/layout/item_section.xml create mode 100644 app/src/main/res/layout/item_youtube_list.xml create mode 100644 app/src/main/res/layout/item_youtube_navigation.xml create mode 100644 app/src/main/res/layout/item_youtube_square.xml diff --git a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt b/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt index 68931f221..4f8821f66 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt @@ -1,6 +1,8 @@ package com.zionhuang.music.extensions import androidx.paging.PagingSource.LoadResult +import com.zionhuang.innertube.models.BrowseResult +import com.zionhuang.innertube.models.SearchResult import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage import org.schabi.newpipe.extractor.ListInfo @@ -16,4 +18,16 @@ fun InfoItemsPage.toPage() = LoadResult.Page( data = items, nextKey = nextPage, prevKey = null +) + +fun SearchResult.toPage() = LoadResult.Page( + data = items, + nextKey = continuation, + prevKey = null +) + +fun BrowseResult.toPage() = LoadResult.Page( + data = sections, + nextKey = continuation, + prevKey = null ) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt b/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt new file mode 100644 index 000000000..4c94cea20 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt @@ -0,0 +1,43 @@ +package com.zionhuang.music.repos + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.Item +import com.zionhuang.innertube.models.Section +import com.zionhuang.innertube.models.SuggestionItem +import com.zionhuang.music.extensions.toPage +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +object YouTubeRepository { + fun search(query: String, filter: YouTube.SearchFilter): PagingSource = object : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult = withContext(IO) { + try { + if (params.key == null) YouTube.search(query, filter).toPage() + else YouTube.search(YouTube.Continuation(params.key!!)).toPage() + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): String? = null + } + + fun browse(browseId: String): PagingSource = object : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult = withContext(IO) { + try { + if (params.key == null) YouTube.browse(browseId).toBrowseResult().toPage() + else YouTube.browse(YouTube.Continuation(params.key!!)).toBrowseResult().toPage() + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): String? = null + } + + suspend fun getSuggestions(query: String): List = withContext(IO) { + YouTube.getSearchSuggestions(query) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/SectionAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/SectionAdapter.kt new file mode 100644 index 000000000..8fee6ac62 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/SectionAdapter.kt @@ -0,0 +1,48 @@ +package com.zionhuang.music.ui.adapters + +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.zionhuang.innertube.models.* +import com.zionhuang.music.R +import com.zionhuang.music.extensions.inflateWithBinding +import com.zionhuang.music.ui.viewholders.SectionViewHolder + +class SectionAdapter : PagingDataAdapter(SectionComparator()) { + private val viewPool = RecyclerView.RecycledViewPool() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder = when (viewType) { + SECTION_ITEM -> SectionViewHolder(parent.inflateWithBinding(R.layout.item_section)) + SECTION_CAROUSEL -> SectionViewHolder(parent.inflateWithBinding(R.layout.item_section)) + SECTION_GRID -> SectionViewHolder(parent.inflateWithBinding(R.layout.item_section)) + SECTION_DESCRIPTION -> SectionViewHolder(parent.inflateWithBinding(R.layout.item_section)) + else -> throw IllegalArgumentException("Unexpected item type.") + } + + override fun onBindViewHolder(holder: SectionViewHolder, position: Int) { + getItem(position)?.let { holder.bind(it) } + } + + override fun getItemViewType(position: Int): Int = when (getItem(position)) { + is ItemSection -> SECTION_ITEM + is CarouselSection -> SECTION_CAROUSEL + is GridSection -> SECTION_GRID + is DescriptionSection -> SECTION_DESCRIPTION + else -> throw IllegalArgumentException("Unknown item type") + } + + fun getItemByPosition(position: Int) = getItem(position) + + class SectionComparator : DiffUtil.ItemCallback
() { + override fun areItemsTheSame(oldItem: Section, newItem: Section): Boolean = oldItem == newItem + override fun areContentsTheSame(oldItem: Section, newItem: Section): Boolean = false + } + + companion object { + const val SECTION_ITEM = 1 + const val SECTION_CAROUSEL = 2 + const val SECTION_GRID = 3 + const val SECTION_DESCRIPTION = 4 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt new file mode 100644 index 000000000..c2a96ae39 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt @@ -0,0 +1,44 @@ +package com.zionhuang.music.ui.adapters + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.zionhuang.innertube.models.Item +import com.zionhuang.innertube.models.NavigationItem +import com.zionhuang.music.R +import com.zionhuang.music.extensions.inflateWithBinding +import com.zionhuang.music.ui.viewholders.YouTubeItemViewHolder +import com.zionhuang.music.ui.viewholders.YouTubeListItemViewHolder +import com.zionhuang.music.ui.viewholders.YouTubeSquareItemViewHolder + +class YouTubeItemAdapter(private val itemStyle: ItemStyle) : ListAdapter(ItemComparator()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): YouTubeItemViewHolder = when (itemStyle) { + ItemStyle.LIST -> YouTubeListItemViewHolder(parent.inflateWithBinding(R.layout.item_youtube_list)) + ItemStyle.SQUARE -> YouTubeSquareItemViewHolder(parent.inflateWithBinding(R.layout.item_youtube_square)) + } + + override fun onBindViewHolder(holder: YouTubeItemViewHolder, position: Int) { + getItem(position)?.let { holder.bind(it) } + } + + override fun getItemViewType(position: Int): Int = when (getItem(position)) { + is NavigationItem -> ITEM_NAVIGATION + else -> ITEM_OTHER + } + + class ItemComparator : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean = oldItem === newItem + override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean = oldItem == newItem + } + + enum class ItemStyle { + LIST, + SQUARE + } + + companion object { + const val ITEM_NAVIGATION = 0 + const val ITEM_OTHER = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt b/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt index 321bbe2d6..fd105e025 100644 --- a/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt @@ -6,11 +6,15 @@ import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.databinding.BindingAdapter +import com.zionhuang.innertube.models.Thumbnail import com.zionhuang.music.R import com.zionhuang.music.constants.MediaConstants.ArtworkType import com.zionhuang.music.constants.MediaConstants.TYPE_RECTANGLE import com.zionhuang.music.constants.MediaConstants.TYPE_SQUARE -import com.zionhuang.music.extensions.* +import com.zionhuang.music.extensions.circle +import com.zionhuang.music.extensions.fullResolution +import com.zionhuang.music.extensions.load +import com.zionhuang.music.extensions.roundCorner import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.widgets.PlayPauseButton import com.zionhuang.music.ui.widgets.RepeatButton @@ -94,3 +98,13 @@ fun setUrl( } } } + +@BindingAdapter("thumbnails") +fun setThumbnails(view: ImageView, thumbnails: List) { + thumbnails.lastOrNull()?.let { + view.load(it.url) { + placeholder(R.drawable.ic_music_note) + roundCorner(view.context.resources.getDimensionPixelSize(R.dimen.song_cover_radius)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt index 02d98e3e6..d6a7c5bad 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt @@ -6,17 +6,24 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.transition.MaterialFadeThrough +import com.zionhuang.innertube.YouTube.HOME_BROWSE_ID import com.zionhuang.music.R -import com.zionhuang.music.databinding.FragmentExploreBinding +import com.zionhuang.music.databinding.LayoutRecyclerviewBinding +import com.zionhuang.music.ui.adapters.SectionAdapter import com.zionhuang.music.ui.fragments.base.BindingFragment import com.zionhuang.music.viewmodels.ExploreViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch -class ExploreFragment : BindingFragment() { - override fun getViewBinding() = FragmentExploreBinding.inflate(layoutInflater) +class ExploreFragment : BindingFragment() { + override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) private val exploreViewModel by viewModels() + private val sectionAdapter = SectionAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -25,8 +32,15 @@ class ExploreFragment : BindingFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - - + binding.recyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = sectionAdapter + } + lifecycleScope.launch { + exploreViewModel.browse(HOME_BROWSE_ID).collectLatest { + sectionAdapter.submitData(it) + } + } } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SectionViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SectionViewHolder.kt new file mode 100644 index 000000000..ef32cc362 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/SectionViewHolder.kt @@ -0,0 +1,50 @@ +package com.zionhuang.music.ui.viewholders + +import androidx.core.view.isVisible +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL +import androidx.recyclerview.widget.RecyclerView +import com.zionhuang.innertube.models.* +import com.zionhuang.music.databinding.ItemSectionBinding +import com.zionhuang.music.extensions.context +import com.zionhuang.music.ui.adapters.YouTubeItemAdapter +import com.zionhuang.music.ui.adapters.YouTubeItemAdapter.ItemStyle.LIST +import com.zionhuang.music.ui.adapters.YouTubeItemAdapter.ItemStyle.SQUARE + +class SectionViewHolder(val binding: ItemSectionBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(section: Section) { + binding.header.isVisible = section.header != null + section.header?.let { + binding.title.text = it.title + binding.subtitle.isVisible = !it.subtitle.isNullOrEmpty() + binding.subtitle.text = it.subtitle + binding.btnMore.isVisible = it.moreNavigationEndpoint != null + } + binding.description.isVisible = section is DescriptionSection + binding.recyclerView.isVisible = section !is DescriptionSection + when (section) { + is DescriptionSection -> { + binding.description.text = section.description + } + is ItemSection -> { + val itemAdapter = YouTubeItemAdapter(LIST) + binding.recyclerView.layoutManager = LinearLayoutManager(binding.context) + binding.recyclerView.adapter = itemAdapter + itemAdapter.submitList(section.items) + } + is CarouselSection -> { + val itemAdapter = YouTubeItemAdapter(SQUARE) + binding.recyclerView.layoutManager = LinearLayoutManager(binding.context, HORIZONTAL, false) + binding.recyclerView.adapter = itemAdapter + itemAdapter.submitList(section.items) + } + is GridSection -> { + val itemAdapter = YouTubeItemAdapter(SQUARE) + binding.recyclerView.layoutManager = GridLayoutManager(binding.context, 2) // TODO spanCount for bigger screen + binding.recyclerView.adapter = itemAdapter + itemAdapter.submitList(section.items) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeItemViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeItemViewHolder.kt new file mode 100644 index 000000000..e8c005230 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeItemViewHolder.kt @@ -0,0 +1,30 @@ +package com.zionhuang.music.ui.viewholders + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView +import com.zionhuang.innertube.models.Item +import com.zionhuang.music.databinding.ItemYoutubeListBinding +import com.zionhuang.music.databinding.ItemYoutubeNavigationBinding +import com.zionhuang.music.databinding.ItemYoutubeSquareBinding + +sealed class YouTubeItemViewHolder(open val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { + abstract fun bind(item: Item) +} + +class YouTubeListItemViewHolder(override val binding: ItemYoutubeListBinding) : YouTubeItemViewHolder(binding) { + override fun bind(item: Item) { + binding.item = item + } +} + +class YouTubeSquareItemViewHolder(override val binding: ItemYoutubeSquareBinding) : YouTubeItemViewHolder(binding) { + override fun bind(item: Item) { + binding.item = item + } +} + +class YouTubeNavigationItemViewHolder(override val binding: ItemYoutubeNavigationBinding) : YouTubeItemViewHolder(binding) { + override fun bind(item: Item) { + binding.title.text = item.title + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt index ca92efba7..2054eab84 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt @@ -2,7 +2,14 @@ package com.zionhuang.music.viewmodels import android.app.Application import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.zionhuang.music.repos.YouTubeRepository class ExploreViewModel(application: Application) : AndroidViewModel(application) { - + fun browse(browseId: String) = Pager(PagingConfig(pageSize = 20)) { + YouTubeRepository.browse(browseId) + }.flow.cachedIn(viewModelScope) } \ No newline at end of file diff --git a/app/src/main/res/layout/item_section.xml b/app/src/main/res/layout/item_section.xml new file mode 100644 index 000000000..65ae0e681 --- /dev/null +++ b/app/src/main/res/layout/item_section.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + +