0.6.0
This commit is contained in:
1
providers/common/.gitignore
vendored
Normal file
1
providers/common/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
19
providers/common/build.gradle.kts
Normal file
19
providers/common/build.gradle.kts
Normal file
@@ -0,0 +1,19 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.datetime)
|
||||
|
||||
implementation(libs.ktor.http)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
|
||||
detektPlugins(libs.detekt.compose)
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package it.hamy.extensions
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
inline fun <T> runCatchingCancellable(block: () -> T) =
|
||||
runCatching(block).takeIf { it.exceptionOrNull() !is CancellationException }
|
||||
@@ -0,0 +1,26 @@
|
||||
package it.hamy.extensions
|
||||
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
object UrlSerializer : KSerializer<Url> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("Url", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder) = Url(decoder.decodeString())
|
||||
override fun serialize(encoder: Encoder, value: Url) = encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
typealias SerializableUrl = @Serializable(with = UrlSerializer::class) Url
|
||||
|
||||
object Iso8601DateSerializer : KSerializer<LocalDateTime> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("Iso8601LocalDateTime", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder) = LocalDateTime.parse(decoder.decodeString().removeSuffix("Z"))
|
||||
override fun serialize(encoder: Encoder, value: LocalDateTime) = encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
typealias SerializableIso8601Date = @Serializable(with = Iso8601DateSerializer::class) LocalDateTime
|
||||
1
providers/github/.gitignore
vendored
Normal file
1
providers/github/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
24
providers/github/build.gradle.kts
Normal file
24
providers/github/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.providers.common)
|
||||
|
||||
implementation(libs.kotlin.coroutines)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.serialization)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
|
||||
detektPlugins(libs.detekt.compose)
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||
}
|
||||
55
providers/github/src/main/kotlin/it/hamy/github/GitHub.kt
Normal file
55
providers/github/src/main/kotlin/it/hamy/github/GitHub.kt
Normal file
@@ -0,0 +1,55 @@
|
||||
package it.hamy.github
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.accept
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val API_VERSION = "2022-11-28"
|
||||
private const val CONTENT_TYPE = "application"
|
||||
private const val CONTENT_SUBTYPE = "vnd.github+json"
|
||||
|
||||
object GitHub {
|
||||
internal val httpClient by lazy {
|
||||
HttpClient(CIO) {
|
||||
val contentType = ContentType(CONTENT_TYPE, CONTENT_SUBTYPE)
|
||||
|
||||
install(ContentNegotiation) {
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
json(json)
|
||||
json(
|
||||
json = json,
|
||||
contentType = contentType
|
||||
)
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url("https://api.github.com")
|
||||
headers["X-GitHub-Api-Version"] = API_VERSION
|
||||
|
||||
accept(contentType)
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
|
||||
expectSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpRequestBuilder.withPagination(size: Int, page: Int) {
|
||||
require(page > 0) { "GitHub error: invalid page ($page), pagination starts at page 1" }
|
||||
require(size > 0) { "GitHub error: invalid page size ($size), a page has to have at least a single item" }
|
||||
|
||||
parameter("per_page", size)
|
||||
parameter("page", page)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package it.hamy.github.models
|
||||
|
||||
import it.hamy.extensions.SerializableUrl
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Reactions(
|
||||
val url: SerializableUrl,
|
||||
@SerialName("total_count")
|
||||
val count: Int,
|
||||
@SerialName("+1")
|
||||
val likes: Int,
|
||||
@SerialName("-1")
|
||||
val dislikes: Int,
|
||||
@SerialName("laugh")
|
||||
val laughs: Int,
|
||||
val confused: Int,
|
||||
@SerialName("heart")
|
||||
val hearts: Int,
|
||||
@SerialName("hooray")
|
||||
val hoorays: Int,
|
||||
val eyes: Int,
|
||||
@SerialName("rocket")
|
||||
val rockets: Int
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package it.hamy.github.models
|
||||
|
||||
import it.hamy.extensions.SerializableIso8601Date
|
||||
import it.hamy.extensions.SerializableUrl
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Release(
|
||||
val id: Int,
|
||||
@SerialName("node_id")
|
||||
val nodeId: String,
|
||||
val url: SerializableUrl,
|
||||
@SerialName("html_url")
|
||||
val frontendUrl: SerializableUrl,
|
||||
@SerialName("assets_url")
|
||||
val assetsUrl: SerializableUrl,
|
||||
@SerialName("tag_name")
|
||||
val tag: String,
|
||||
val name: String? = null,
|
||||
@SerialName("body")
|
||||
val markdown: String? = null,
|
||||
val draft: Boolean,
|
||||
@SerialName("prerelease")
|
||||
val preRelease: Boolean,
|
||||
@SerialName("created_at")
|
||||
val createdAt: SerializableIso8601Date,
|
||||
@SerialName("published_at")
|
||||
val publishedAt: SerializableIso8601Date? = null,
|
||||
val author: SimpleUser,
|
||||
val assets: List<Asset> = emptyList(),
|
||||
@SerialName("body_html")
|
||||
val html: String? = null,
|
||||
@SerialName("body_text")
|
||||
val text: String? = null,
|
||||
@SerialName("discussion_url")
|
||||
val discussionUrl: SerializableUrl? = null,
|
||||
val reactions: Reactions? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Asset(
|
||||
val url: SerializableUrl,
|
||||
@SerialName("browser_download_url")
|
||||
val downloadUrl: SerializableUrl,
|
||||
val id: Int,
|
||||
@SerialName("node_id")
|
||||
val nodeId: String,
|
||||
val name: String,
|
||||
val label: String? = null,
|
||||
val state: State,
|
||||
@SerialName("content_type")
|
||||
val contentType: String,
|
||||
val size: Long,
|
||||
@SerialName("download_count")
|
||||
val downloads: Int,
|
||||
@SerialName("created_at")
|
||||
val createdAt: SerializableIso8601Date,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: SerializableIso8601Date,
|
||||
val uploader: SimpleUser? = null
|
||||
) {
|
||||
@Serializable
|
||||
enum class State {
|
||||
@SerialName("uploaded")
|
||||
Uploaded,
|
||||
|
||||
@SerialName("open")
|
||||
Open
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package it.hamy.github.models
|
||||
|
||||
import it.hamy.extensions.SerializableUrl
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SimpleUser(
|
||||
val name: String? = null,
|
||||
val email: String? = null,
|
||||
val login: String,
|
||||
val id: Int,
|
||||
@SerialName("node_id")
|
||||
val nodeId: String,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: SerializableUrl,
|
||||
@SerialName("gravatar_id")
|
||||
val gravatarId: String? = null,
|
||||
val url: SerializableUrl,
|
||||
@SerialName("html_url")
|
||||
val frontendUrl: SerializableUrl,
|
||||
@SerialName("followers_url")
|
||||
val followersUrl: SerializableUrl,
|
||||
@SerialName("following_url")
|
||||
val followingUrl: SerializableUrl,
|
||||
@SerialName("gists_url")
|
||||
val gistsUrl: SerializableUrl,
|
||||
@SerialName("starred_url")
|
||||
val starredUrl: SerializableUrl,
|
||||
@SerialName("subscriptions_url")
|
||||
val subscriptionsUrl: SerializableUrl,
|
||||
@SerialName("organizations_url")
|
||||
val organizationsUrl: SerializableUrl,
|
||||
@SerialName("repos_url")
|
||||
val reposUrl: SerializableUrl,
|
||||
@SerialName("events_url")
|
||||
val eventsUrl: SerializableUrl,
|
||||
@SerialName("received_events_url")
|
||||
val receivedEventsUrl: SerializableUrl,
|
||||
val type: String,
|
||||
@SerialName("site_admin")
|
||||
val admin: Boolean
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package it.hamy.github.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.github.GitHub
|
||||
import it.hamy.github.models.Release
|
||||
|
||||
suspend fun GitHub.releases(
|
||||
owner: String,
|
||||
repo: String,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 30
|
||||
) = runCatchingCancellable {
|
||||
httpClient.get("repos/$owner/$repo/releases") {
|
||||
withPagination(page = page, size = pageSize)
|
||||
}.body<List<Release>>()
|
||||
}
|
||||
1
providers/innertube/.gitignore
vendored
Normal file
1
providers/innertube/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
28
providers/innertube/build.gradle.kts
Normal file
28
providers/innertube/build.gradle.kts
Normal file
@@ -0,0 +1,28 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.ktorClientBrotli)
|
||||
implementation(projects.providers.common)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.encoding)
|
||||
implementation(libs.ktor.client.serialization)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
|
||||
detektPlugins(libs.detekt.compose)
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll("-Xcontext-receivers")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package it.hamy.innertube
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.compression.ContentEncoding
|
||||
import io.ktor.client.plugins.compression.brotli
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.parameters
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import it.hamy.innertube.models.MusicNavigationButtonRenderer
|
||||
import it.hamy.innertube.models.NavigationEndpoint
|
||||
import it.hamy.innertube.models.Runs
|
||||
import it.hamy.innertube.models.Thumbnail
|
||||
import it.hamy.innertube.utils.ProxyPreferences
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
object Innertube {
|
||||
val client = HttpClient(OkHttp) {
|
||||
expectSuccess = true
|
||||
|
||||
install(ContentNegotiation) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
install(ContentEncoding) {
|
||||
brotli(1.0f)
|
||||
gzip(0.9f)
|
||||
deflate(0.8f)
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url(scheme = "https", host = "music.youtube.com") {
|
||||
contentType(ContentType.Application.Json)
|
||||
headers {
|
||||
append("X-Goog-Api-Key", API_KEY)
|
||||
append("x-origin", ORIGIN)
|
||||
}
|
||||
parameters {
|
||||
append("prettyPrint", "false")
|
||||
append("key", API_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProxyPreferences.preference?.let {
|
||||
engine {
|
||||
proxy = Proxy(
|
||||
it.proxyMode,
|
||||
InetSocketAddress(
|
||||
it.proxyHost,
|
||||
it.proxyPort
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
|
||||
private const val ORIGIN = "https://music.youtube.com"
|
||||
|
||||
internal const val BROWSE = "/youtubei/v1/browse"
|
||||
internal const val NEXT = "/youtubei/v1/next"
|
||||
internal const val PLAYER = "/youtubei/v1/player"
|
||||
internal const val QUEUE = "/youtubei/v1/music/get_queue"
|
||||
internal const val SEARCH = "/youtubei/v1/search"
|
||||
internal const val SEARCH_SUGGESTIONS = "/youtubei/v1/music/get_search_suggestions"
|
||||
internal const val MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK =
|
||||
"musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)"
|
||||
internal const val MUSIC_TWO_ROW_ITEM_RENDERER_MASK =
|
||||
"musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)"
|
||||
internal const val PLAYLIST_PANEL_VIDEO_RENDERER_MASK =
|
||||
"playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)"
|
||||
|
||||
internal fun HttpRequestBuilder.mask(value: String = "*") =
|
||||
header("X-Goog-FieldMask", value)
|
||||
|
||||
data class Info<T : NavigationEndpoint.Endpoint>(
|
||||
val name: String?,
|
||||
val endpoint: T?
|
||||
) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
constructor(run: Runs.Run) : this(
|
||||
name = run.text,
|
||||
endpoint = run.navigationEndpoint?.endpoint as T?
|
||||
)
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class SearchFilter(val value: String) {
|
||||
companion object {
|
||||
val Song = SearchFilter("EgWKAQIIAWoOEAMQBBAJEAoQBRAQEBU%3D")
|
||||
val Video = SearchFilter("EgWKAQIQAWoOEAMQBBAJEAoQBRAQEBU%3D")
|
||||
val Album = SearchFilter("EgWKAQIYAWoOEAMQBBAJEAoQBRAQEBU%3D")
|
||||
val Artist = SearchFilter("EgWKAQIgAWoOEAMQBBAJEAoQBRAQEBU%3D")
|
||||
val CommunityPlaylist = SearchFilter("EgeKAQQoAEABag4QAxAEEAkQChAFEBAQFQ%3D%3D")
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Item {
|
||||
abstract val thumbnail: Thumbnail?
|
||||
abstract val key: String
|
||||
}
|
||||
|
||||
data class SongItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val album: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val durationText: String?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.videoId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class VideoItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val viewsText: String?,
|
||||
val durationText: String?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.videoId!!
|
||||
|
||||
val isOfficialMusicVideo: Boolean
|
||||
get() = info
|
||||
?.endpoint
|
||||
?.watchEndpointMusicSupportedConfigs
|
||||
?.watchEndpointMusicConfig
|
||||
?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV"
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class AlbumItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val year: String?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.browseId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class ArtistItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val subscribersCountText: String?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.browseId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class PlaylistItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val channel: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val songCount: Int?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.browseId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class ArtistPage(
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val thumbnail: Thumbnail?,
|
||||
val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?,
|
||||
val radioEndpoint: NavigationEndpoint.Endpoint.Watch?,
|
||||
val songs: List<SongItem>?,
|
||||
val songsEndpoint: NavigationEndpoint.Endpoint.Browse?,
|
||||
val albums: List<AlbumItem>?,
|
||||
val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?,
|
||||
val singles: List<AlbumItem>?,
|
||||
val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?
|
||||
)
|
||||
|
||||
data class PlaylistOrAlbumPage(
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val year: String?,
|
||||
val thumbnail: Thumbnail?,
|
||||
val url: String?,
|
||||
val songsPage: ItemsPage<SongItem>?,
|
||||
val otherVersions: List<AlbumItem>?,
|
||||
val otherInfo: String?
|
||||
)
|
||||
|
||||
data class NextPage(
|
||||
val itemsPage: ItemsPage<SongItem>?,
|
||||
val playlistId: String?,
|
||||
val params: String? = null,
|
||||
val playlistSetVideoId: String? = null
|
||||
)
|
||||
|
||||
data class RelatedPage(
|
||||
val songs: List<SongItem>? = null,
|
||||
val playlists: List<PlaylistItem>? = null,
|
||||
val albums: List<AlbumItem>? = null,
|
||||
val artists: List<ArtistItem>? = null
|
||||
)
|
||||
|
||||
data class DiscoverPage(
|
||||
val newReleaseAlbums: List<AlbumItem>,
|
||||
val moods: List<Mood.Item>
|
||||
)
|
||||
|
||||
data class Mood(
|
||||
val title: String,
|
||||
val items: List<Item>
|
||||
) {
|
||||
data class Item(
|
||||
val title: String,
|
||||
val stripeColor: Long,
|
||||
val endpoint: NavigationEndpoint.Endpoint.Browse
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
fun MusicNavigationButtonRenderer.toMood(): Mood.Item? {
|
||||
return Mood.Item(
|
||||
title = buttonText.runs.firstOrNull()?.text ?: return null,
|
||||
stripeColor = solid?.leftStripeColor ?: return null,
|
||||
endpoint = clickCommand.browseEndpoint ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
data class ItemsPage<T : Item>(
|
||||
val items: List<T>?,
|
||||
val continuation: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@Serializable
|
||||
data class BrowseResponse(
|
||||
val contents: Contents?,
|
||||
val header: Header?,
|
||||
val microformat: Microformat?
|
||||
) {
|
||||
@Serializable
|
||||
data class Contents(
|
||||
val singleColumnBrowseResultsRenderer: Tabs?,
|
||||
val sectionListRenderer: SectionListRenderer?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
data class Header(
|
||||
@JsonNames("musicVisualHeaderRenderer")
|
||||
val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?,
|
||||
val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicDetailHeaderRenderer(
|
||||
val title: Runs?,
|
||||
val description: Runs?,
|
||||
val subtitle: Runs?,
|
||||
val secondSubtitle: Runs?,
|
||||
val thumbnail: ThumbnailRenderer?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicImmersiveHeaderRenderer(
|
||||
val description: Runs?,
|
||||
val playButton: PlayButton?,
|
||||
val startRadioButton: StartRadioButton?,
|
||||
val thumbnail: ThumbnailRenderer?,
|
||||
val foregroundThumbnail: ThumbnailRenderer?,
|
||||
val title: Runs?
|
||||
) {
|
||||
@Serializable
|
||||
data class PlayButton(
|
||||
val buttonRenderer: ButtonRenderer?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StartRadioButton(
|
||||
val buttonRenderer: ButtonRenderer?
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Microformat(
|
||||
val microformatDataRenderer: MicroformatDataRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MicroformatDataRenderer(
|
||||
val urlCanonical: String?
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ButtonRenderer(
|
||||
val navigationEndpoint: NavigationEndpoint?
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.http.HttpMessageBuilder
|
||||
import io.ktor.http.userAgent
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
data class Context(
|
||||
val client: Client,
|
||||
val thirdParty: ThirdParty? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Client(
|
||||
val clientName: String,
|
||||
val clientVersion: String,
|
||||
val platform: String,
|
||||
val hl: String = "en",
|
||||
val gl: String = "US",
|
||||
val visitorData: String = DEFAULT_VISITOR_DATA,
|
||||
val androidSdkVersion: Int? = null,
|
||||
val userAgent: String? = null,
|
||||
val referer: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ThirdParty(
|
||||
val embedUrl: String
|
||||
)
|
||||
|
||||
context(HttpMessageBuilder)
|
||||
fun apply() {
|
||||
client.userAgent?.let { userAgent(it) }
|
||||
|
||||
headers {
|
||||
client.referer?.let { append("Referer", it) }
|
||||
append("X-Youtube-Bootstrap-Logged-In", "false")
|
||||
append("X-YouTube-Client-Name", client.clientName)
|
||||
append("X-YouTube-Client-Version", client.clientVersion)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D"
|
||||
|
||||
val DefaultWeb get() = DefaultWebNoLang.let {
|
||||
it.copy(
|
||||
client = it.client.copy(
|
||||
hl = Locale.getDefault().toLanguageTag(),
|
||||
gl = Locale.getDefault().country
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val DefaultWebNoLang = Context(
|
||||
client = Client(
|
||||
clientName = "WEB_REMIX",
|
||||
clientVersion = "1.20220606.03.00",
|
||||
platform = "DESKTOP",
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36",
|
||||
referer = "https://music.youtube.com/"
|
||||
)
|
||||
)
|
||||
|
||||
val DefaultAndroid = Context(
|
||||
client = Client(
|
||||
clientName = "ANDROID_MUSIC",
|
||||
clientVersion = "5.28.1",
|
||||
platform = "MOBILE",
|
||||
androidSdkVersion = 30,
|
||||
userAgent = "com.google.android.apps.youtube.music/5.28.1 (Linux; U; Android 11) gzip"
|
||||
)
|
||||
)
|
||||
|
||||
val DefaultAgeRestrictionBypass = Context(
|
||||
client = Client(
|
||||
clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
||||
clientVersion = "2.0",
|
||||
platform = "TV",
|
||||
userAgent = "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class Continuation(
|
||||
@JsonNames("nextContinuationData", "nextRadioContinuationData")
|
||||
val nextContinuationData: Data?
|
||||
) {
|
||||
@Serializable
|
||||
data class Data(
|
||||
val continuation: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class ContinuationResponse(
|
||||
val continuationContents: ContinuationContents?
|
||||
) {
|
||||
@Serializable
|
||||
data class ContinuationContents(
|
||||
@JsonNames("musicPlaylistShelfContinuation")
|
||||
val musicShelfContinuation: MusicShelfRenderer?,
|
||||
val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GetQueueResponse(
|
||||
@SerialName("queueDatas")
|
||||
val queueData: List<QueueData>?
|
||||
) {
|
||||
@Serializable
|
||||
data class QueueData(
|
||||
val content: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GridRenderer(
|
||||
val items: List<Item>?,
|
||||
val header: Header?
|
||||
) {
|
||||
@Serializable
|
||||
data class Item(
|
||||
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Header(
|
||||
val gridHeaderRenderer: GridHeaderRenderer?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GridHeaderRenderer(
|
||||
val title: Runs?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MusicCarouselShelfRenderer(
|
||||
val header: Header?,
|
||||
val contents: List<Content>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
|
||||
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
|
||||
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Header(
|
||||
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
|
||||
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
|
||||
val musicCarouselShelfBasicHeaderRenderer: MusicCarouselShelfBasicHeaderRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicCarouselShelfBasicHeaderRenderer(
|
||||
val moreContentButton: MoreContentButton?,
|
||||
val title: Runs?,
|
||||
val strapline: Runs?
|
||||
) {
|
||||
@Serializable
|
||||
data class MoreContentButton(
|
||||
val buttonRenderer: ButtonRenderer?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MusicNavigationButtonRenderer(
|
||||
val buttonText: Runs,
|
||||
val solid: Solid?,
|
||||
val iconStyle: IconStyle?,
|
||||
val clickCommand: NavigationEndpoint
|
||||
) {
|
||||
@Serializable
|
||||
data class Solid(
|
||||
val leftStripeColor: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IconStyle(
|
||||
val icon: Icon
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Icon(
|
||||
val iconType: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class MusicResponsiveListItemRenderer(
|
||||
val fixedColumns: List<FlexColumn>?,
|
||||
val flexColumns: List<FlexColumn>,
|
||||
val thumbnail: ThumbnailRenderer?,
|
||||
val navigationEndpoint: NavigationEndpoint?
|
||||
) {
|
||||
@Serializable
|
||||
data class FlexColumn(
|
||||
@JsonNames("musicResponsiveListItemFixedColumnRenderer")
|
||||
val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicResponsiveListItemFlexColumnRenderer(
|
||||
val text: Runs?
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MusicShelfRenderer(
|
||||
val bottomEndpoint: NavigationEndpoint?,
|
||||
val contents: List<Content>?,
|
||||
val continuations: List<Continuation>?,
|
||||
val title: Runs?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?
|
||||
) {
|
||||
val runs: Pair<List<Runs.Run>, List<List<Runs.Run>>>
|
||||
get() = musicResponsiveListItemRenderer
|
||||
?.flexColumns
|
||||
?.firstOrNull()
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
.orEmpty() to
|
||||
musicResponsiveListItemRenderer
|
||||
?.flexColumns
|
||||
?.let { it.getOrNull(1) ?: it.lastOrNull() }
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.splitBySeparator()
|
||||
.orEmpty()
|
||||
|
||||
val thumbnail: Thumbnail?
|
||||
get() = musicResponsiveListItemRenderer
|
||||
?.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MusicTwoRowItemRenderer(
|
||||
val navigationEndpoint: NavigationEndpoint?,
|
||||
val thumbnailRenderer: ThumbnailRenderer?,
|
||||
val title: Runs?,
|
||||
val subtitle: Runs?,
|
||||
val thumbnailOverlay: ThumbnailOverlay?
|
||||
) {
|
||||
val isPlaylist: Boolean
|
||||
get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
|
||||
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST"
|
||||
|
||||
val isAlbum: Boolean
|
||||
get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
|
||||
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ALBUM" ||
|
||||
navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
|
||||
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_AUDIOBOOK"
|
||||
|
||||
val isArtist: Boolean
|
||||
get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
|
||||
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ARTIST"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* watchPlaylistEndpoint: params, playlistId
|
||||
* watchEndpoint: params, playlistId, videoId, index
|
||||
* browseEndpoint: params, browseId
|
||||
* searchEndpoint: params, query
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data class NavigationEndpoint(
|
||||
val watchEndpoint: Endpoint.Watch?,
|
||||
val watchPlaylistEndpoint: Endpoint.WatchPlaylist?,
|
||||
val browseEndpoint: Endpoint.Browse?,
|
||||
val searchEndpoint: Endpoint.Search?
|
||||
) {
|
||||
val endpoint get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint
|
||||
|
||||
@Serializable
|
||||
sealed class Endpoint {
|
||||
@Serializable
|
||||
data class Watch(
|
||||
val params: String? = null,
|
||||
val playlistId: String? = null,
|
||||
val videoId: String? = null,
|
||||
val index: Int? = null,
|
||||
val playlistSetVideoId: String? = null,
|
||||
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null
|
||||
) : Endpoint() {
|
||||
@Serializable
|
||||
data class WatchEndpointMusicSupportedConfigs(
|
||||
val watchEndpointMusicConfig: WatchEndpointMusicConfig?
|
||||
) {
|
||||
@Serializable
|
||||
data class WatchEndpointMusicConfig(
|
||||
val musicVideoType: String?
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class WatchPlaylist(
|
||||
val params: String?,
|
||||
val playlistId: String?
|
||||
) : Endpoint()
|
||||
|
||||
@Serializable
|
||||
data class Browse(
|
||||
val params: String? = null,
|
||||
val browseId: String? = null,
|
||||
val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null
|
||||
) : Endpoint() {
|
||||
val type: String?
|
||||
get() = browseEndpointContextSupportedConfigs
|
||||
?.browseEndpointContextMusicConfig
|
||||
?.pageType
|
||||
|
||||
@Serializable
|
||||
data class BrowseEndpointContextSupportedConfigs(
|
||||
val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig
|
||||
) {
|
||||
@Serializable
|
||||
data class BrowseEndpointContextMusicConfig(
|
||||
val pageType: String
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Search(
|
||||
val params: String?,
|
||||
val query: String
|
||||
) : Endpoint()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class NextResponse(
|
||||
val contents: Contents?
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicQueueRenderer(
|
||||
val content: Content?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
@JsonNames("playlistPanelContinuation")
|
||||
val playlistPanelRenderer: PlaylistPanelRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class PlaylistPanelRenderer(
|
||||
val contents: List<Content>?,
|
||||
val continuations: List<Continuation>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?,
|
||||
val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class AutomixPreviewVideoRenderer(
|
||||
val content: Content?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val automixPlaylistVideoRenderer: AutomixPlaylistVideoRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class AutomixPlaylistVideoRenderer(
|
||||
val navigationEndpoint: NavigationEndpoint?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Contents(
|
||||
val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class SingleColumnMusicWatchNextResultsRenderer(
|
||||
val tabbedRenderer: TabbedRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class TabbedRenderer(
|
||||
val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class WatchNextTabbedResultsRenderer(
|
||||
val tabs: List<Tab>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Tab(
|
||||
val tabRenderer: TabRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class TabRenderer(
|
||||
val content: Content?,
|
||||
val endpoint: NavigationEndpoint?,
|
||||
val title: String?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val musicQueueRenderer: MusicQueueRenderer?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayerResponse(
|
||||
val playabilityStatus: PlayabilityStatus?,
|
||||
val playerConfig: PlayerConfig?,
|
||||
val streamingData: StreamingData?,
|
||||
val videoDetails: VideoDetails?
|
||||
) {
|
||||
@Serializable
|
||||
data class PlayabilityStatus(
|
||||
val status: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayerConfig(
|
||||
val audioConfig: AudioConfig?
|
||||
) {
|
||||
@Serializable
|
||||
data class AudioConfig(
|
||||
private val loudnessDb: Double?
|
||||
) {
|
||||
// For music clients only
|
||||
val normalizedLoudnessDb: Float?
|
||||
get() = loudnessDb?.plus(7)?.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StreamingData(
|
||||
val adaptiveFormats: List<AdaptiveFormat>?
|
||||
) {
|
||||
val highestQualityFormat: AdaptiveFormat?
|
||||
get() = adaptiveFormats?.findLast { it.itag == 251 || it.itag == 140 }
|
||||
|
||||
@Serializable
|
||||
data class AdaptiveFormat(
|
||||
val itag: Int,
|
||||
val mimeType: String,
|
||||
val bitrate: Long?,
|
||||
val averageBitrate: Long?,
|
||||
val contentLength: Long?,
|
||||
val audioQuality: String?,
|
||||
val approxDurationMs: Long?,
|
||||
val lastModified: Long?,
|
||||
val loudnessDb: Double?,
|
||||
val audioSampleRate: Int?,
|
||||
val url: String?
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class VideoDetails(
|
||||
val videoId: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlaylistPanelVideoRenderer(
|
||||
val title: Runs?,
|
||||
val longBylineText: Runs?,
|
||||
val shortBylineText: Runs?,
|
||||
val lengthText: Runs?,
|
||||
val navigationEndpoint: NavigationEndpoint?,
|
||||
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail?
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Runs(
|
||||
val runs: List<Run> = listOf()
|
||||
) {
|
||||
companion object {
|
||||
const val SEPARATOR = " • "
|
||||
}
|
||||
|
||||
val text: String
|
||||
get() = runs.joinToString("") { it.text.orEmpty() }
|
||||
|
||||
fun splitBySeparator(): List<List<Run>> {
|
||||
return runs.flatMapIndexed { index, run ->
|
||||
when {
|
||||
index == 0 || index == runs.lastIndex -> listOf(index)
|
||||
run.text == SEPARATOR -> listOf(index - 1, index + 1)
|
||||
else -> emptyList()
|
||||
}
|
||||
}.windowed(size = 2, step = 2) { (from, to) -> runs.slice(from..to) }.let {
|
||||
it.ifEmpty {
|
||||
listOf(runs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Run(
|
||||
val text: String?,
|
||||
val navigationEndpoint: NavigationEndpoint?
|
||||
)
|
||||
}
|
||||
|
||||
fun List<Runs.Run>.splitBySeparator(): List<List<Runs.Run>> {
|
||||
val res = mutableListOf<List<Runs.Run>>()
|
||||
var tmp = mutableListOf<Runs.Run>()
|
||||
forEach { run ->
|
||||
if (run.text == " • ") {
|
||||
res.add(tmp)
|
||||
tmp = mutableListOf()
|
||||
} else {
|
||||
tmp.add(run)
|
||||
}
|
||||
}
|
||||
res.add(tmp)
|
||||
return res
|
||||
}
|
||||
|
||||
fun <T> List<T>.oddElements() = filterIndexed { index, _ -> index % 2 == 0 }
|
||||
@@ -0,0 +1,13 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchResponse(
|
||||
val contents: Contents?
|
||||
) {
|
||||
@Serializable
|
||||
data class Contents(
|
||||
val tabbedSearchResultsRenderer: Tabs?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchSuggestionsResponse(
|
||||
val contents: List<Content>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class SearchSuggestionsSectionRenderer(
|
||||
val contents: List<Content>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val searchSuggestionRenderer: SearchSuggestionRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class SearchSuggestionRenderer(
|
||||
val navigationEndpoint: NavigationEndpoint?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class SectionListRenderer(
|
||||
val contents: List<Content>?,
|
||||
val continuations: List<Continuation>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
@JsonNames("musicImmersiveCarouselShelfRenderer")
|
||||
val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?,
|
||||
@JsonNames("musicPlaylistShelfRenderer")
|
||||
val musicShelfRenderer: MusicShelfRenderer?,
|
||||
val gridRenderer: GridRenderer?,
|
||||
val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class MusicDescriptionShelfRenderer(
|
||||
val description: Runs?
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Tabs(
|
||||
val tabs: List<Tab>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Tab(
|
||||
val tabRenderer: TabRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class TabRenderer(
|
||||
val content: Content?,
|
||||
val title: String?,
|
||||
val tabIdentifier: String?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val sectionListRenderer: SectionListRenderer?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Thumbnail(
|
||||
val url: String,
|
||||
val height: Int?,
|
||||
val width: Int?
|
||||
) {
|
||||
fun size(size: Int) = when {
|
||||
url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size"
|
||||
url.startsWith("https://yt3.ggpht.com") -> "$url-s$size"
|
||||
else -> url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package it.hamy.innertube.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class ThumbnailRenderer(
|
||||
@JsonNames("croppedSquareThumbnailRenderer")
|
||||
val musicThumbnailRenderer: MusicThumbnailRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicThumbnailRenderer(
|
||||
val thumbnail: Thumbnail?
|
||||
) {
|
||||
@Serializable
|
||||
data class Thumbnail(
|
||||
val thumbnails: List<it.hamy.innertube.models.Thumbnail>?
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ThumbnailOverlay(
|
||||
val musicItemThumbnailOverlayRenderer: MusicItemThumbnailOverlayRenderer
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicItemThumbnailOverlayRenderer(
|
||||
val content: Content
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val musicPlayButtonRenderer: MusicPlayButtonRenderer
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicPlayButtonRenderer(
|
||||
val playNavigationEndpoint: NavigationEndpoint?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.hamy.innertube.models.bodies
|
||||
|
||||
import it.hamy.innertube.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BrowseBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val browseId: String,
|
||||
val params: String? = null
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package it.hamy.innertube.models.bodies
|
||||
|
||||
import it.hamy.innertube.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ContinuationBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val continuation: String
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package it.hamy.innertube.models.bodies
|
||||
|
||||
import it.hamy.innertube.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NextBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val videoId: String?,
|
||||
val isAudioOnly: Boolean = true,
|
||||
val playlistId: String? = null,
|
||||
val tunerSettingValue: String = "AUTOMIX_SETTING_NORMAL",
|
||||
val index: Int? = null,
|
||||
val params: String? = null,
|
||||
val playlistSetVideoId: String? = null,
|
||||
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs = WatchEndpointMusicSupportedConfigs(
|
||||
musicVideoType = "MUSIC_VIDEO_TYPE_ATV"
|
||||
)
|
||||
) {
|
||||
@Serializable
|
||||
data class WatchEndpointMusicSupportedConfigs(
|
||||
val musicVideoType: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.hamy.innertube.models.bodies
|
||||
|
||||
import it.hamy.innertube.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayerBody(
|
||||
val context: Context = Context.DefaultAndroid,
|
||||
val videoId: String,
|
||||
val playlistId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.hamy.innertube.models.bodies
|
||||
|
||||
import it.hamy.innertube.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class QueueBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val videoIds: List<String>? = null,
|
||||
val playlistId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.hamy.innertube.models.bodies
|
||||
|
||||
import it.hamy.innertube.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val query: String,
|
||||
val params: String
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package it.hamy.innertube.models.bodies
|
||||
|
||||
import it.hamy.innertube.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchSuggestionsBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val input: String
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.http.Url
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.NavigationEndpoint
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
|
||||
suspend fun Innertube.albumPage(body: BrowseBody) = playlistPage(body)?.map { album ->
|
||||
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
|
||||
playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist ->
|
||||
album.copy(songsPage = playlist.songsPage)
|
||||
}
|
||||
} ?: album
|
||||
}?.map { album ->
|
||||
val albumInfo = Innertube.Info(
|
||||
name = album.title,
|
||||
endpoint = NavigationEndpoint.Endpoint.Browse(
|
||||
browseId = body.browseId,
|
||||
params = body.params
|
||||
)
|
||||
)
|
||||
|
||||
album.copy(
|
||||
songsPage = album.songsPage?.copy(
|
||||
items = album.songsPage.items?.map { song ->
|
||||
song.copy(
|
||||
authors = song.authors ?: album.authors,
|
||||
album = albumInfo,
|
||||
thumbnail = album.thumbnail
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.BrowseResponse
|
||||
import it.hamy.innertube.models.Context
|
||||
import it.hamy.innertube.models.MusicCarouselShelfRenderer
|
||||
import it.hamy.innertube.models.MusicShelfRenderer
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
import it.hamy.innertube.utils.findSectionByTitle
|
||||
import it.hamy.innertube.utils.from
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
|
||||
suspend fun Innertube.artistPage(body: BrowseBody) = runCatchingCancellable {
|
||||
val ctx = currentCoroutineContext()
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(body)
|
||||
mask("contents,header")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
val responseNoLang by lazy {
|
||||
CoroutineScope(ctx).async(start = CoroutineStart.LAZY) {
|
||||
client.post(BROWSE) {
|
||||
setBody(body.copy(context = Context.DefaultWebNoLang))
|
||||
mask("contents,header")
|
||||
}.body<BrowseResponse>()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findSectionByTitle(text: String) = response
|
||||
.contents
|
||||
?.singleColumnBrowseResultsRenderer
|
||||
?.tabs
|
||||
?.get(0)
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.findSectionByTitle(text) ?: responseNoLang.await()
|
||||
.contents
|
||||
?.singleColumnBrowseResultsRenderer
|
||||
?.tabs
|
||||
?.get(0)
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.findSectionByTitle(text)
|
||||
|
||||
val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer
|
||||
val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer
|
||||
val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer
|
||||
|
||||
Innertube.ArtistPage(
|
||||
name = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.title
|
||||
?.text,
|
||||
description = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.description
|
||||
?.text,
|
||||
thumbnail = (
|
||||
response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.foregroundThumbnail
|
||||
?: response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.thumbnail
|
||||
)
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.getOrNull(0),
|
||||
shuffleEndpoint = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.playButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchEndpoint,
|
||||
radioEndpoint = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.startRadioButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchEndpoint,
|
||||
songs = songsSection
|
||||
?.contents
|
||||
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(Innertube.SongItem::from),
|
||||
songsEndpoint = songsSection
|
||||
?.bottomEndpoint
|
||||
?.browseEndpoint,
|
||||
albums = albumsSection
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.AlbumItem::from),
|
||||
albumsEndpoint = albumsSection
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.moreContentButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint,
|
||||
singles = singlesSection
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.AlbumItem::from),
|
||||
singlesEndpoint = singlesSection
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.moreContentButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.BrowseResponse
|
||||
import it.hamy.innertube.models.MusicTwoRowItemRenderer
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
import it.hamy.innertube.utils.from
|
||||
|
||||
suspend fun Innertube.browse(body: BrowseBody) = runCatchingCancellable {
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(body)
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
BrowseResult(
|
||||
title = response.header?.musicImmersiveHeaderRenderer?.title?.text ?: response.header
|
||||
?.musicDetailHeaderRenderer?.title?.text,
|
||||
items = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()
|
||||
?.tabRenderer?.content?.sectionListRenderer?.contents?.mapNotNull { content ->
|
||||
when {
|
||||
content.gridRenderer != null -> BrowseResult.Item(
|
||||
title = content.gridRenderer.header?.gridHeaderRenderer?.title?.runs
|
||||
?.firstOrNull()?.text ?: return@mapNotNull null,
|
||||
items = content.gridRenderer.items?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() }
|
||||
.orEmpty()
|
||||
)
|
||||
|
||||
content.musicCarouselShelfRenderer != null -> BrowseResult.Item(
|
||||
title = content
|
||||
.musicCarouselShelfRenderer
|
||||
.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.text ?: return@mapNotNull null,
|
||||
items = content
|
||||
.musicCarouselShelfRenderer
|
||||
.contents
|
||||
?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() }
|
||||
.orEmpty()
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
data class BrowseResult(
|
||||
val title: String?,
|
||||
val items: List<Item>
|
||||
) {
|
||||
data class Item(
|
||||
val title: String,
|
||||
val items: List<Innertube.Item>
|
||||
)
|
||||
}
|
||||
|
||||
fun MusicTwoRowItemRenderer.toItem() = when {
|
||||
isAlbum -> Innertube.AlbumItem.from(this)
|
||||
isPlaylist -> Innertube.PlaylistItem.from(this)
|
||||
isArtist -> Innertube.ArtistItem.from(this)
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.BrowseResponse
|
||||
import it.hamy.innertube.models.MusicTwoRowItemRenderer
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
import it.hamy.innertube.models.oddElements
|
||||
import it.hamy.innertube.models.splitBySeparator
|
||||
|
||||
suspend fun Innertube.discoverPage() = runCatchingCancellable {
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(BrowseBody(browseId = "FEmusic_explore"))
|
||||
mask("contents")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
Innertube.DiscoverPage(
|
||||
newReleaseAlbums = response.contents?.singleColumnBrowseResultsRenderer?.tabs
|
||||
?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.find {
|
||||
it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint
|
||||
?.browseId == "FEmusic_new_releases_albums"
|
||||
}?.musicCarouselShelfRenderer?.contents?.mapNotNull { it.musicTwoRowItemRenderer?.toNewReleaseAlbumPage() }
|
||||
.orEmpty(),
|
||||
moods = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()
|
||||
?.tabRenderer?.content?.sectionListRenderer?.contents?.find {
|
||||
it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint
|
||||
?.browseId == "FEmusic_moods_and_genres"
|
||||
}?.musicCarouselShelfRenderer?.contents?.mapNotNull { it.musicNavigationButtonRenderer?.toMood() }
|
||||
.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
fun MusicTwoRowItemRenderer.toNewReleaseAlbumPage() = Innertube.AlbumItem(
|
||||
info = Innertube.Info(
|
||||
name = title?.text,
|
||||
endpoint = navigationEndpoint?.browseEndpoint
|
||||
),
|
||||
authors = subtitle?.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map {
|
||||
Innertube.Info(
|
||||
name = it.text,
|
||||
endpoint = it.navigationEndpoint?.browseEndpoint
|
||||
)
|
||||
},
|
||||
year = subtitle?.runs?.lastOrNull()?.text,
|
||||
thumbnail = thumbnailRenderer?.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull()
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.BrowseResponse
|
||||
import it.hamy.innertube.models.ContinuationResponse
|
||||
import it.hamy.innertube.models.GridRenderer
|
||||
import it.hamy.innertube.models.MusicResponsiveListItemRenderer
|
||||
import it.hamy.innertube.models.MusicShelfRenderer
|
||||
import it.hamy.innertube.models.MusicTwoRowItemRenderer
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
import it.hamy.innertube.models.bodies.ContinuationBody
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.itemsPage(
|
||||
body: BrowseBody,
|
||||
fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null },
|
||||
fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }
|
||||
) = runCatchingCancellable {
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(body)
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
val sectionListRendererContent = response
|
||||
.contents
|
||||
?.singleColumnBrowseResultsRenderer
|
||||
?.tabs
|
||||
?.firstOrNull()
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.firstOrNull()
|
||||
|
||||
itemsPageFromMusicShelRendererOrGridRenderer(
|
||||
musicShelfRenderer = sectionListRendererContent
|
||||
?.musicShelfRenderer,
|
||||
gridRenderer = sectionListRendererContent
|
||||
?.gridRenderer,
|
||||
fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer,
|
||||
fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.itemsPage(
|
||||
body: ContinuationBody,
|
||||
fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null },
|
||||
fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }
|
||||
) = runCatchingCancellable {
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(body)
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
itemsPageFromMusicShelRendererOrGridRenderer(
|
||||
musicShelfRenderer = response
|
||||
.continuationContents
|
||||
?.musicShelfContinuation,
|
||||
gridRenderer = null,
|
||||
fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer,
|
||||
fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T : Innertube.Item> itemsPageFromMusicShelRendererOrGridRenderer(
|
||||
musicShelfRenderer: MusicShelfRenderer?,
|
||||
gridRenderer: GridRenderer?,
|
||||
fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T?,
|
||||
fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T?
|
||||
) = when {
|
||||
musicShelfRenderer != null -> Innertube.ItemsPage(
|
||||
continuation = musicShelfRenderer
|
||||
.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation,
|
||||
items = musicShelfRenderer
|
||||
.contents
|
||||
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(fromMusicResponsiveListItemRenderer)
|
||||
)
|
||||
|
||||
gridRenderer != null -> Innertube.ItemsPage(
|
||||
continuation = null,
|
||||
items = gridRenderer
|
||||
.items
|
||||
?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(fromMusicTwoRowItemRenderer)
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.BrowseResponse
|
||||
import it.hamy.innertube.models.NextResponse
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
import it.hamy.innertube.models.bodies.NextBody
|
||||
|
||||
suspend fun Innertube.lyrics(body: NextBody) = runCatchingCancellable {
|
||||
val nextResponse = client.post(NEXT) {
|
||||
setBody(body)
|
||||
@Suppress("all")
|
||||
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)")
|
||||
}.body<NextResponse>()
|
||||
|
||||
val browseId = nextResponse
|
||||
.contents
|
||||
?.singleColumnMusicWatchNextResultsRenderer
|
||||
?.tabbedRenderer
|
||||
?.watchNextTabbedResultsRenderer
|
||||
?.tabs
|
||||
?.getOrNull(1)
|
||||
?.tabRenderer
|
||||
?.endpoint
|
||||
?.browseEndpoint
|
||||
?.browseId
|
||||
?: return@runCatchingCancellable null
|
||||
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(BrowseBody(browseId = browseId))
|
||||
mask("contents.sectionListRenderer.contents.musicDescriptionShelfRenderer.description")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
response.contents
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.firstOrNull()
|
||||
?.musicDescriptionShelfRenderer
|
||||
?.description
|
||||
?.text
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.ContinuationResponse
|
||||
import it.hamy.innertube.models.NextResponse
|
||||
import it.hamy.innertube.models.bodies.ContinuationBody
|
||||
import it.hamy.innertube.models.bodies.NextBody
|
||||
import it.hamy.innertube.utils.from
|
||||
|
||||
suspend fun Innertube.nextPage(body: NextBody): Result<Innertube.NextPage>? =
|
||||
runCatchingCancellable {
|
||||
val response = client.post(NEXT) {
|
||||
setBody(body)
|
||||
@Suppress("all")
|
||||
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$PLAYLIST_PANEL_VIDEO_RENDERER_MASK))")
|
||||
}.body<NextResponse>()
|
||||
|
||||
val tabs = response
|
||||
.contents
|
||||
?.singleColumnMusicWatchNextResultsRenderer
|
||||
?.tabbedRenderer
|
||||
?.watchNextTabbedResultsRenderer
|
||||
?.tabs
|
||||
|
||||
val playlistPanelRenderer = tabs
|
||||
?.getOrNull(0)
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.musicQueueRenderer
|
||||
?.content
|
||||
?.playlistPanelRenderer
|
||||
|
||||
if (body.playlistId == null) {
|
||||
val endpoint = playlistPanelRenderer
|
||||
?.contents
|
||||
?.lastOrNull()
|
||||
?.automixPreviewVideoRenderer
|
||||
?.content
|
||||
?.automixPlaylistVideoRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchPlaylistEndpoint
|
||||
|
||||
if (endpoint != null) return nextPage(
|
||||
body.copy(
|
||||
playlistId = endpoint.playlistId,
|
||||
params = endpoint.params
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Innertube.NextPage(
|
||||
playlistId = body.playlistId,
|
||||
playlistSetVideoId = body.playlistSetVideoId,
|
||||
params = body.params,
|
||||
itemsPage = playlistPanelRenderer
|
||||
?.toSongsPage()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingCancellable {
|
||||
val response = client.post(NEXT) {
|
||||
setBody(body)
|
||||
@Suppress("all")
|
||||
mask("continuationContents.playlistPanelContinuation(continuations,contents.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK)")
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
response
|
||||
.continuationContents
|
||||
?.playlistPanelContinuation
|
||||
?.toSongsPage()
|
||||
}
|
||||
|
||||
private fun NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?.toSongsPage() =
|
||||
Innertube.ItemsPage(
|
||||
items = this
|
||||
?.contents
|
||||
?.mapNotNull(
|
||||
NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content
|
||||
::playlistPanelVideoRenderer
|
||||
)?.mapNotNull(Innertube.SongItem::from),
|
||||
continuation = this
|
||||
?.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.Context
|
||||
import it.hamy.innertube.models.PlayerResponse
|
||||
import it.hamy.innertube.models.bodies.PlayerBody
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
suspend fun Innertube.player(body: PlayerBody) = runCatchingCancellable {
|
||||
val response = client.post(PLAYER) {
|
||||
setBody(body)
|
||||
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId")
|
||||
}.body<PlayerResponse>()
|
||||
|
||||
if (response.playabilityStatus?.status == "OK") {
|
||||
response
|
||||
} else {
|
||||
@Serializable
|
||||
data class AudioStream(
|
||||
val url: String,
|
||||
val bitrate: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PipedResponse(
|
||||
val audioStreams: List<AudioStream>
|
||||
)
|
||||
|
||||
val safePlayerResponse = client.post(PLAYER) {
|
||||
setBody(
|
||||
body.copy(
|
||||
context = Context.DefaultAgeRestrictionBypass.copy(
|
||||
thirdParty = Context.ThirdParty(
|
||||
embedUrl = "https://www.youtube.com/watch?v=${body.videoId}"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId")
|
||||
}.body<PlayerResponse>()
|
||||
|
||||
if (safePlayerResponse.playabilityStatus?.status != "OK") {
|
||||
return@runCatchingCancellable response
|
||||
}
|
||||
|
||||
val audioStreams = client.get("https://pipedapi.adminforge.de/streams/${body.videoId}") {
|
||||
contentType(ContentType.Application.Json)
|
||||
}.body<PipedResponse>().audioStreams
|
||||
|
||||
safePlayerResponse.copy(
|
||||
streamingData = safePlayerResponse.streamingData?.copy(
|
||||
adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat ->
|
||||
adaptiveFormat.copy(
|
||||
url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.BrowseResponse
|
||||
import it.hamy.innertube.models.ContinuationResponse
|
||||
import it.hamy.innertube.models.MusicCarouselShelfRenderer
|
||||
import it.hamy.innertube.models.MusicShelfRenderer
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
import it.hamy.innertube.models.bodies.ContinuationBody
|
||||
import it.hamy.innertube.utils.from
|
||||
|
||||
suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingCancellable {
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(body)
|
||||
body.context.apply()
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
val musicDetailHeaderRenderer = response
|
||||
.header
|
||||
?.musicDetailHeaderRenderer
|
||||
|
||||
val sectionListRendererContents = response
|
||||
.contents
|
||||
?.singleColumnBrowseResultsRenderer
|
||||
?.tabs
|
||||
?.firstOrNull()
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
|
||||
val musicShelfRenderer = sectionListRendererContents
|
||||
?.firstOrNull()
|
||||
?.musicShelfRenderer
|
||||
|
||||
val musicCarouselShelfRenderer = sectionListRendererContents
|
||||
?.getOrNull(1)
|
||||
?.musicCarouselShelfRenderer
|
||||
|
||||
Innertube.PlaylistOrAlbumPage(
|
||||
title = musicDetailHeaderRenderer
|
||||
?.title
|
||||
?.text,
|
||||
description = musicDetailHeaderRenderer
|
||||
?.description
|
||||
?.text,
|
||||
thumbnail = musicDetailHeaderRenderer
|
||||
?.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull(),
|
||||
authors = musicDetailHeaderRenderer
|
||||
?.subtitle
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(1)
|
||||
?.map(Innertube::Info),
|
||||
year = musicDetailHeaderRenderer
|
||||
?.subtitle
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(2)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
url = response
|
||||
.microformat
|
||||
?.microformatDataRenderer
|
||||
?.urlCanonical,
|
||||
songsPage = musicShelfRenderer
|
||||
?.toSongsPage(),
|
||||
otherVersions = musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.AlbumItem::from),
|
||||
otherInfo = musicDetailHeaderRenderer
|
||||
?.secondSubtitle
|
||||
?.text
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingCancellable {
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(body)
|
||||
parameter("continuation", body.continuation)
|
||||
parameter("ctoken", body.continuation)
|
||||
parameter("type", "next")
|
||||
body.context.apply()
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
response
|
||||
.continuationContents
|
||||
?.musicShelfContinuation
|
||||
?.toSongsPage()
|
||||
}
|
||||
|
||||
private fun MusicShelfRenderer?.toSongsPage() = Innertube.ItemsPage(
|
||||
items = this
|
||||
?.contents
|
||||
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(Innertube.SongItem::from),
|
||||
continuation = this
|
||||
?.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.GetQueueResponse
|
||||
import it.hamy.innertube.models.bodies.QueueBody
|
||||
import it.hamy.innertube.utils.from
|
||||
|
||||
suspend fun Innertube.queue(body: QueueBody) = runCatchingCancellable {
|
||||
val response = client.post(QUEUE) {
|
||||
setBody(body)
|
||||
mask("queueDatas.content.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK")
|
||||
}.body<GetQueueResponse>()
|
||||
|
||||
response
|
||||
.queueData
|
||||
?.mapNotNull { queueData ->
|
||||
queueData
|
||||
.content
|
||||
?.playlistPanelVideoRenderer
|
||||
?.let(Innertube.SongItem::from)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Innertube.song(videoId: String): Result<Innertube.SongItem?>? =
|
||||
queue(QueueBody(videoIds = listOf(videoId)))?.map { it?.firstOrNull() }
|
||||
@@ -0,0 +1,84 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.BrowseResponse
|
||||
import it.hamy.innertube.models.Context
|
||||
import it.hamy.innertube.models.MusicCarouselShelfRenderer
|
||||
import it.hamy.innertube.models.NextResponse
|
||||
import it.hamy.innertube.models.bodies.BrowseBody
|
||||
import it.hamy.innertube.models.bodies.NextBody
|
||||
import it.hamy.innertube.utils.findSectionByStrapline
|
||||
import it.hamy.innertube.utils.findSectionByTitle
|
||||
import it.hamy.innertube.utils.from
|
||||
|
||||
suspend fun Innertube.relatedPage(body: NextBody) = runCatchingCancellable {
|
||||
val nextResponse = client.post(NEXT) {
|
||||
setBody(body.copy(context = Context.DefaultWebNoLang))
|
||||
@Suppress("all")
|
||||
mask(
|
||||
"contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)"
|
||||
)
|
||||
}.body<NextResponse>()
|
||||
|
||||
val browseId = nextResponse
|
||||
.contents
|
||||
?.singleColumnMusicWatchNextResultsRenderer
|
||||
?.tabbedRenderer
|
||||
?.watchNextTabbedResultsRenderer
|
||||
?.tabs
|
||||
?.getOrNull(2)
|
||||
?.tabRenderer
|
||||
?.endpoint
|
||||
?.browseEndpoint
|
||||
?.browseId
|
||||
?: return@runCatchingCancellable null
|
||||
|
||||
val response = client.post(BROWSE) {
|
||||
setBody(
|
||||
BrowseBody(
|
||||
browseId = browseId,
|
||||
context = Context.DefaultWebNoLang
|
||||
)
|
||||
)
|
||||
@Suppress("all")
|
||||
mask(
|
||||
"contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK,$MUSIC_TWO_ROW_ITEM_RENDERER_MASK))"
|
||||
)
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
val sectionListRenderer = response
|
||||
.contents
|
||||
?.sectionListRenderer
|
||||
|
||||
Innertube.RelatedPage(
|
||||
songs = sectionListRenderer
|
||||
?.findSectionByTitle("You might also like")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(Innertube.SongItem::from),
|
||||
playlists = sectionListRenderer
|
||||
?.findSectionByTitle("Recommended playlists")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.PlaylistItem::from)
|
||||
?.sortedByDescending { it.channel?.name == "YouTube Music" },
|
||||
albums = sectionListRenderer
|
||||
?.findSectionByStrapline("MORE FROM")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.AlbumItem::from),
|
||||
artists = sectionListRenderer
|
||||
?.findSectionByTitle("Similar artists")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.ArtistItem::from)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.ContinuationResponse
|
||||
import it.hamy.innertube.models.MusicShelfRenderer
|
||||
import it.hamy.innertube.models.SearchResponse
|
||||
import it.hamy.innertube.models.bodies.ContinuationBody
|
||||
import it.hamy.innertube.models.bodies.SearchBody
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.searchPage(
|
||||
body: SearchBody,
|
||||
fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?
|
||||
) = runCatchingCancellable {
|
||||
val response = client.post(SEARCH) {
|
||||
setBody(body)
|
||||
@Suppress("all")
|
||||
mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)")
|
||||
}.body<SearchResponse>()
|
||||
|
||||
response
|
||||
.contents
|
||||
?.tabbedSearchResultsRenderer
|
||||
?.tabs
|
||||
?.firstOrNull()
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.lastOrNull()
|
||||
?.musicShelfRenderer
|
||||
?.toItemsPage(fromMusicShelfRendererContent)
|
||||
}
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.searchPage(
|
||||
body: ContinuationBody,
|
||||
fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?
|
||||
) = runCatchingCancellable {
|
||||
val response = client.post(SEARCH) {
|
||||
setBody(body)
|
||||
@Suppress("all")
|
||||
mask("continuationContents.musicShelfContinuation(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)")
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
response
|
||||
.continuationContents
|
||||
?.musicShelfContinuation
|
||||
?.toItemsPage(fromMusicShelfRendererContent)
|
||||
}
|
||||
|
||||
private fun <T : Innertube.Item> MusicShelfRenderer?.toItemsPage(
|
||||
mapper: (MusicShelfRenderer.Content) -> T?
|
||||
) = Innertube.ItemsPage(
|
||||
items = this
|
||||
?.contents
|
||||
?.mapNotNull(mapper),
|
||||
continuation = this
|
||||
?.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package it.hamy.innertube.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.SearchSuggestionsResponse
|
||||
import it.hamy.innertube.models.bodies.SearchSuggestionsBody
|
||||
|
||||
suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingCancellable {
|
||||
val response = client.post(SEARCH_SUGGESTIONS) {
|
||||
setBody(body)
|
||||
@Suppress("all")
|
||||
mask("contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query")
|
||||
}.body<SearchSuggestionsResponse>()
|
||||
|
||||
response
|
||||
.contents
|
||||
?.firstOrNull()
|
||||
?.searchSuggestionsSectionRenderer
|
||||
?.contents
|
||||
?.mapNotNull { content ->
|
||||
content
|
||||
.searchSuggestionRenderer
|
||||
?.navigationEndpoint
|
||||
?.searchEndpoint
|
||||
?.query
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package it.hamy.innertube.utils
|
||||
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.MusicResponsiveListItemRenderer
|
||||
import it.hamy.innertube.models.NavigationEndpoint
|
||||
|
||||
fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) =
|
||||
Innertube.SongItem(
|
||||
info = renderer
|
||||
.flexColumns
|
||||
.getOrNull(0)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.getOrNull(0)
|
||||
?.let {
|
||||
if (it.navigationEndpoint?.endpoint is NavigationEndpoint.Endpoint.Watch) Innertube.Info(
|
||||
name = it.text,
|
||||
endpoint = it.navigationEndpoint.endpoint as NavigationEndpoint.Endpoint.Watch
|
||||
) else null
|
||||
},
|
||||
authors = renderer
|
||||
.flexColumns
|
||||
.getOrNull(1)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.map { Innertube.Info(name = it.text, endpoint = it.navigationEndpoint?.endpoint) }
|
||||
?.filterIsInstance<Innertube.Info<NavigationEndpoint.Endpoint.Browse>>()
|
||||
?.takeIf(List<Any>::isNotEmpty),
|
||||
durationText = renderer
|
||||
.fixedColumns
|
||||
?.getOrNull(0)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.getOrNull(0)
|
||||
?.text,
|
||||
album = renderer
|
||||
.flexColumns
|
||||
.getOrNull(2)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
thumbnail = renderer
|
||||
.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
@@ -0,0 +1,143 @@
|
||||
package it.hamy.innertube.utils
|
||||
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.MusicShelfRenderer
|
||||
import it.hamy.innertube.models.NavigationEndpoint
|
||||
|
||||
fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.SongItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
// Possible configurations:
|
||||
// "song" • author(s) • album • duration
|
||||
// "song" • author(s) • duration
|
||||
// author(s) • album • duration
|
||||
// author(s) • duration
|
||||
|
||||
val album: Innertube.Info<NavigationEndpoint.Endpoint.Browse>? = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 1)
|
||||
?.firstOrNull()
|
||||
?.takeIf { run ->
|
||||
run
|
||||
.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
?.type == "MUSIC_PAGE_TYPE_ALBUM"
|
||||
}
|
||||
?.let(Innertube::Info)
|
||||
|
||||
return Innertube.SongItem(
|
||||
info = mainRuns
|
||||
.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
authors = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2)
|
||||
?.map(Innertube::Info),
|
||||
album = album,
|
||||
durationText = otherRuns
|
||||
.lastOrNull()
|
||||
?.firstOrNull()
|
||||
?.text
|
||||
?.takeIf { ':' in it }
|
||||
?: otherRuns
|
||||
.getOrNull(otherRuns.size - 2)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
thumbnail = content.thumbnail
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
}
|
||||
|
||||
fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.VideoItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return runCatching {
|
||||
Innertube.VideoItem(
|
||||
info = mainRuns
|
||||
.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
authors = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 2)
|
||||
?.map(Innertube::Info),
|
||||
viewsText = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 1)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
durationText = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
thumbnail = content.thumbnail
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.AlbumItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Innertube.AlbumItem(
|
||||
info = Innertube.Info(
|
||||
name = mainRuns
|
||||
.firstOrNull()
|
||||
?.text,
|
||||
endpoint = content
|
||||
.musicResponsiveListItemRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
),
|
||||
authors = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 1)
|
||||
?.map(Innertube::Info),
|
||||
year = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
thumbnail = content.thumbnail
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
|
||||
fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.ArtistItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Innertube.ArtistItem(
|
||||
info = Innertube.Info(
|
||||
name = mainRuns
|
||||
.firstOrNull()
|
||||
?.text,
|
||||
endpoint = content
|
||||
.musicResponsiveListItemRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
),
|
||||
subscribersCountText = otherRuns
|
||||
.lastOrNull()
|
||||
?.last()
|
||||
?.text,
|
||||
thumbnail = content.thumbnail
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
|
||||
fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.PlaylistItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Innertube.PlaylistItem(
|
||||
info = Innertube.Info(
|
||||
name = mainRuns
|
||||
.firstOrNull()
|
||||
?.text,
|
||||
endpoint = content
|
||||
.musicResponsiveListItemRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
),
|
||||
channel = otherRuns
|
||||
.firstOrNull()
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
songCount = otherRuns
|
||||
.lastOrNull()
|
||||
?.firstOrNull()
|
||||
?.text
|
||||
?.split(' ')
|
||||
?.firstOrNull()
|
||||
?.toIntOrNull(),
|
||||
thumbnail = content.thumbnail
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package it.hamy.innertube.utils
|
||||
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.MusicTwoRowItemRenderer
|
||||
|
||||
fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.AlbumItem(
|
||||
info = renderer
|
||||
.title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
authors = null,
|
||||
year = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.lastOrNull()
|
||||
?.text,
|
||||
thumbnail = renderer
|
||||
.thumbnailRenderer
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
|
||||
fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.ArtistItem(
|
||||
info = renderer
|
||||
.title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
subscribersCountText = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
thumbnail = renderer
|
||||
.thumbnailRenderer
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
|
||||
fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer) =
|
||||
Innertube.PlaylistItem(
|
||||
info = renderer
|
||||
.title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
channel = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.getOrNull(2)
|
||||
?.let(Innertube::Info),
|
||||
songCount = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.getOrNull(4)
|
||||
?.text
|
||||
?.split(' ')
|
||||
?.firstOrNull()
|
||||
?.toIntOrNull(),
|
||||
thumbnail = renderer
|
||||
.thumbnailRenderer
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
@@ -0,0 +1,33 @@
|
||||
package it.hamy.innertube.utils
|
||||
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.PlaylistPanelVideoRenderer
|
||||
|
||||
fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer) = Innertube.SongItem(
|
||||
info = Innertube.Info(
|
||||
name = renderer
|
||||
.title
|
||||
?.text,
|
||||
endpoint = renderer
|
||||
.navigationEndpoint
|
||||
?.watchEndpoint
|
||||
),
|
||||
authors = renderer
|
||||
.longBylineText
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(0)
|
||||
?.map(Innertube::Info),
|
||||
album = renderer
|
||||
.longBylineText
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(1)
|
||||
?.getOrNull(0)
|
||||
?.let(Innertube::Info),
|
||||
thumbnail = renderer
|
||||
.thumbnail
|
||||
?.thumbnails
|
||||
?.getOrNull(0),
|
||||
durationText = renderer
|
||||
.lengthText
|
||||
?.text
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
@@ -0,0 +1,13 @@
|
||||
package it.hamy.innertube.utils
|
||||
|
||||
import java.net.Proxy
|
||||
|
||||
object ProxyPreferences {
|
||||
var preference: ProxyPreferenceItem? = null
|
||||
}
|
||||
|
||||
data class ProxyPreferenceItem(
|
||||
var proxyHost: String,
|
||||
var proxyPort: Int,
|
||||
var proxyMode: Proxy.Type
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package it.hamy.innertube.utils
|
||||
|
||||
import it.hamy.innertube.Innertube
|
||||
import it.hamy.innertube.models.SectionListRenderer
|
||||
|
||||
internal fun SectionListRenderer.findSectionByTitle(text: String) = contents?.find {
|
||||
val title = it
|
||||
.musicCarouselShelfRenderer
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.title
|
||||
?: it
|
||||
.musicShelfRenderer
|
||||
?.title
|
||||
|
||||
title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.text == text
|
||||
}
|
||||
|
||||
internal fun SectionListRenderer.findSectionByStrapline(text: String) = contents?.find {
|
||||
it
|
||||
.musicCarouselShelfRenderer
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.strapline
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.text == text
|
||||
}
|
||||
|
||||
infix operator fun <T : Innertube.Item> Innertube.ItemsPage<T>?.plus(other: Innertube.ItemsPage<T>) =
|
||||
other.copy(
|
||||
items = (this?.items?.plus(other.items ?: emptyList()) ?: other.items)
|
||||
?.distinctBy(Innertube.Item::key),
|
||||
continuation = other.continuation ?: this?.continuation
|
||||
)
|
||||
1
providers/kugou/.gitignore
vendored
Normal file
1
providers/kugou/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
25
providers/kugou/build.gradle.kts
Normal file
25
providers/kugou/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.providers.common)
|
||||
|
||||
implementation(libs.kotlin.coroutines)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.encoding)
|
||||
implementation(libs.ktor.client.serialization)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
|
||||
detektPlugins(libs.detekt.compose)
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||
}
|
||||
184
providers/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt
Normal file
184
providers/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt
Normal file
@@ -0,0 +1,184 @@
|
||||
package it.hamy.kugou
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.BrowserUserAgent
|
||||
import io.ktor.client.plugins.compression.ContentEncoding
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.util.decodeBase64String
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.kugou.models.DownloadLyricsResponse
|
||||
import it.hamy.kugou.models.SearchLyricsResponse
|
||||
import it.hamy.kugou.models.SearchSongResponse
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object KuGou {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val client by lazy {
|
||||
HttpClient(OkHttp) {
|
||||
BrowserUserAgent()
|
||||
|
||||
expectSuccess = true
|
||||
|
||||
install(ContentNegotiation) {
|
||||
val feature = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
json(feature)
|
||||
json(feature, ContentType.Text.Html)
|
||||
json(feature, ContentType.Text.Plain)
|
||||
}
|
||||
|
||||
install(ContentEncoding) {
|
||||
gzip()
|
||||
deflate()
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url("https://krcs.kugou.com")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun lyrics(artist: String, title: String, duration: Long) = runCatchingCancellable {
|
||||
val keyword = keyword(artist, title)
|
||||
val infoByKeyword = searchSong(keyword)
|
||||
|
||||
if (infoByKeyword.isNotEmpty()) {
|
||||
var tolerance = 0
|
||||
|
||||
while (tolerance <= 5) {
|
||||
for (info in infoByKeyword) {
|
||||
if (info.duration >= duration - tolerance && info.duration <= duration + tolerance) {
|
||||
searchLyricsByHash(info.hash).firstOrNull()?.let { candidate ->
|
||||
return@runCatchingCancellable downloadLyrics(
|
||||
candidate.id,
|
||||
candidate.accessKey
|
||||
).normalize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tolerance++
|
||||
}
|
||||
}
|
||||
|
||||
searchLyricsByKeyword(keyword).firstOrNull()?.let { candidate ->
|
||||
return@runCatchingCancellable downloadLyrics(
|
||||
candidate.id,
|
||||
candidate.accessKey
|
||||
).normalize()
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun downloadLyrics(id: Long, accessKey: String) = client.get("/download") {
|
||||
parameter("ver", 1)
|
||||
parameter("man", "yes")
|
||||
parameter("client", "pc")
|
||||
parameter("fmt", "lrc")
|
||||
parameter("id", id)
|
||||
parameter("accesskey", accessKey)
|
||||
}.body<DownloadLyricsResponse>().content.decodeBase64String().let(::Lyrics)
|
||||
|
||||
private suspend fun searchLyricsByHash(hash: String) = client.get("/search") {
|
||||
parameter("ver", 1)
|
||||
parameter("man", "yes")
|
||||
parameter("client", "mobi")
|
||||
parameter("hash", hash)
|
||||
}.body<SearchLyricsResponse>().candidates
|
||||
|
||||
private suspend fun searchLyricsByKeyword(keyword: String) = client.get("/search") {
|
||||
parameter("ver", 1)
|
||||
parameter("man", "yes")
|
||||
parameter("client", "mobi")
|
||||
url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false))
|
||||
}.body<SearchLyricsResponse>().candidates
|
||||
|
||||
private suspend fun searchSong(keyword: String) =
|
||||
client.get("https://mobileservice.kugou.com/api/v3/search/song") {
|
||||
parameter("version", 9108)
|
||||
parameter("plat", 0)
|
||||
parameter("pagesize", 8)
|
||||
parameter("showtype", 0)
|
||||
url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false))
|
||||
}.body<SearchSongResponse>().data.info
|
||||
|
||||
private fun keyword(artist: String, title: String): String {
|
||||
val (newTitle, featuring) = title.extract(" (feat. ", ')')
|
||||
|
||||
val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring")
|
||||
.replace(", ", "、")
|
||||
.replace(" & ", "、")
|
||||
.replace(".", "")
|
||||
|
||||
return "$newArtist - $newTitle"
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair<String, String> {
|
||||
val startIndex = indexOf(startDelimiter).takeIf { it != -1 } ?: return this to ""
|
||||
val endIndex = indexOf(endDelimiter, startIndex).takeIf { it != -1 } ?: return this to ""
|
||||
|
||||
return removeRange(startIndex, endIndex + 1) to substring(startIndex + startDelimiter.length, endIndex)
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class Lyrics(val value: String) {
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
fun normalize(): Lyrics {
|
||||
var toDrop = 0
|
||||
var maybeToDrop = 0
|
||||
|
||||
val text = value.replace("\r\n", "\n").trim()
|
||||
|
||||
for (line in text.lineSequence()) when {
|
||||
line.startsWith("[ti:") ||
|
||||
line.startsWith("[ar:") ||
|
||||
line.startsWith("[al:") ||
|
||||
line.startsWith("[by:") ||
|
||||
line.startsWith("[hash:") ||
|
||||
line.startsWith("[sign:") ||
|
||||
line.startsWith("[qq:") ||
|
||||
line.startsWith("[total:") ||
|
||||
line.startsWith("[offset:") ||
|
||||
line.startsWith("[id:") ||
|
||||
line.containsAt("]Written by:", 9) ||
|
||||
line.containsAt("]Lyrics by:", 9) ||
|
||||
line.containsAt("]Composed by:", 9) ||
|
||||
line.containsAt("]Producer:", 9) ||
|
||||
line.containsAt("]作曲 : ", 9) ||
|
||||
line.containsAt("]作词 : ", 9) -> {
|
||||
toDrop += line.length + 1 + maybeToDrop
|
||||
maybeToDrop = 0
|
||||
}
|
||||
|
||||
maybeToDrop == 0 -> maybeToDrop = line.length + 1
|
||||
|
||||
else -> {
|
||||
maybeToDrop = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities())
|
||||
}
|
||||
|
||||
private fun String.containsAt(charSequence: CharSequence, startIndex: Int) =
|
||||
regionMatches(startIndex, charSequence, 0, charSequence.length)
|
||||
|
||||
private fun String.removeHtmlEntities() = replace("'", "'")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package it.hamy.kugou.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal class DownloadLyricsResponse(
|
||||
val content: String
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package it.hamy.kugou.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal class SearchLyricsResponse(
|
||||
val candidates: List<Candidate>
|
||||
) {
|
||||
@Serializable
|
||||
internal class Candidate(
|
||||
val id: Long,
|
||||
@SerialName("accesskey") val accessKey: String,
|
||||
val duration: Long
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package it.hamy.kugou.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class SearchSongResponse(
|
||||
val data: Data
|
||||
) {
|
||||
@Serializable
|
||||
internal data class Data(
|
||||
val info: List<Info>
|
||||
) {
|
||||
@Serializable
|
||||
internal data class Info(
|
||||
val duration: Long,
|
||||
val hash: String
|
||||
)
|
||||
}
|
||||
}
|
||||
1
providers/lrclib/.gitignore
vendored
Normal file
1
providers/lrclib/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
24
providers/lrclib/build.gradle.kts
Normal file
24
providers/lrclib/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.providers.common)
|
||||
|
||||
implementation(libs.kotlin.coroutines)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.serialization)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
|
||||
detektPlugins(libs.detekt.compose)
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||
}
|
||||
79
providers/lrclib/src/main/kotlin/it/hamy/lrclib/LrcLib.kt
Normal file
79
providers/lrclib/src/main/kotlin/it/hamy/lrclib/LrcLib.kt
Normal file
@@ -0,0 +1,79 @@
|
||||
package it.hamy.lrclib
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.lrclib.models.Track
|
||||
import it.hamy.lrclib.models.bestMatchingFor
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Duration
|
||||
|
||||
object LrcLib {
|
||||
private val client by lazy {
|
||||
HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url("https://lrclib.net")
|
||||
}
|
||||
|
||||
expectSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryLyrics(artist: String, title: String, album: String? = null) =
|
||||
client.get("/api/search") {
|
||||
parameter("track_name", title)
|
||||
parameter("artist_name", artist)
|
||||
if (album != null) parameter("album_name", album)
|
||||
}.body<List<Track>>().filter { it.syncedLyrics != null }
|
||||
|
||||
suspend fun lyrics(
|
||||
artist: String,
|
||||
title: String,
|
||||
duration: Duration,
|
||||
album: String? = null
|
||||
) = runCatchingCancellable {
|
||||
val tracks = queryLyrics(artist, title, album)
|
||||
|
||||
tracks.bestMatchingFor(title, duration)?.syncedLyrics?.let(LrcLib::Lyrics)
|
||||
}
|
||||
|
||||
suspend fun lyrics(artist: String, title: String) = runCatchingCancellable {
|
||||
queryLyrics(artist = artist, title = title, album = null)
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class Lyrics(val text: String) {
|
||||
val sentences
|
||||
get() = runCatching {
|
||||
buildMap {
|
||||
put(0L, "")
|
||||
text.trim().lines().filter { it.length >= 10 }.forEach {
|
||||
put(
|
||||
it[8].digitToInt() * 10L +
|
||||
it[7].digitToInt() * 100 +
|
||||
it[5].digitToInt() * 1000 +
|
||||
it[4].digitToInt() * 10000 +
|
||||
it[2].digitToInt() * 60 * 1000 +
|
||||
it[1].digitToInt() * 600 * 1000,
|
||||
it.substring(10)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package it.hamy.lrclib.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
data class Track(
|
||||
val id: Int,
|
||||
val trackName: String,
|
||||
val artistName: String,
|
||||
val duration: Long,
|
||||
val plainLyrics: String?,
|
||||
val syncedLyrics: String?
|
||||
)
|
||||
|
||||
internal fun List<Track>.bestMatchingFor(title: String, duration: Duration) =
|
||||
firstOrNull { it.duration == duration.inWholeSeconds }
|
||||
?: minByOrNull { abs(it.trackName.length - title.length) }
|
||||
1
providers/piped/.gitignore
vendored
Normal file
1
providers/piped/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
26
providers/piped/build.gradle.kts
Normal file
26
providers/piped/build.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.providers.common)
|
||||
|
||||
implementation(libs.kotlin.coroutines)
|
||||
api(libs.kotlin.datetime)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.serialization)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
api(libs.ktor.http)
|
||||
|
||||
detektPlugins(libs.detekt.compose)
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||
}
|
||||
169
providers/piped/src/main/kotlin/it/hamy/piped/Piped.kt
Normal file
169
providers/piped/src/main/kotlin/it/hamy/piped/Piped.kt
Normal file
@@ -0,0 +1,169 @@
|
||||
package it.hamy.piped
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.accept
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.path
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import it.hamy.extensions.runCatchingCancellable
|
||||
import it.hamy.piped.models.CreatedPlaylist
|
||||
import it.hamy.piped.models.Instance
|
||||
import it.hamy.piped.models.Playlist
|
||||
import it.hamy.piped.models.PlaylistPreview
|
||||
import it.hamy.piped.models.Session
|
||||
import it.hamy.piped.models.authenticatedWith
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
operator fun Url.div(path: String) = URLBuilder(this).apply { path(path) }.build()
|
||||
operator fun JsonElement.div(key: String) = jsonObject[key]!!
|
||||
|
||||
object Piped {
|
||||
private val client by lazy {
|
||||
HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
install(HttpRequestRetry) {
|
||||
exponentialDelay()
|
||||
maxRetries = 2
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
connectTimeoutMillis = 1000L
|
||||
requestTimeoutMillis = 5000L
|
||||
}
|
||||
|
||||
expectSuccess = true
|
||||
|
||||
defaultRequest {
|
||||
accept(ContentType.Application.Json)
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private suspend fun request(
|
||||
session: Session,
|
||||
endpoint: String,
|
||||
block: HttpRequestBuilder.() -> Unit = { }
|
||||
) = mutex.withLock {
|
||||
client.request(url = session.apiBaseUrl / endpoint) {
|
||||
block()
|
||||
header("Authorization", session.token)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun HttpResponse.isOk() =
|
||||
(body<JsonElement>() / "message").jsonPrimitive.content == "ok"
|
||||
|
||||
suspend fun getInstances() = runCatchingCancellable {
|
||||
client.get("https://piped-instances.kavin.rocks/").body<List<Instance>>()
|
||||
}
|
||||
|
||||
suspend fun login(apiBaseUrl: Url, username: String, password: String) =
|
||||
runCatchingCancellable {
|
||||
apiBaseUrl authenticatedWith (
|
||||
client.post(apiBaseUrl / "login") {
|
||||
setBody(
|
||||
mapOf(
|
||||
"username" to username,
|
||||
"password" to password
|
||||
)
|
||||
)
|
||||
}.body<JsonElement>() / "token"
|
||||
).jsonPrimitive.content
|
||||
}
|
||||
|
||||
val playlist = Playlists()
|
||||
|
||||
class Playlists internal constructor() {
|
||||
suspend fun list(session: Session) = runCatchingCancellable {
|
||||
request(session, "user/playlists").body<List<PlaylistPreview>>()
|
||||
}
|
||||
|
||||
suspend fun create(session: Session, name: String) = runCatchingCancellable {
|
||||
request(session, "user/playlists/create") {
|
||||
method = HttpMethod.Post
|
||||
setBody(mapOf("name" to name))
|
||||
}.body<CreatedPlaylist>()
|
||||
}
|
||||
|
||||
suspend fun rename(session: Session, id: UUID, name: String) = runCatchingCancellable {
|
||||
request(session, "user/playlists/rename") {
|
||||
method = HttpMethod.Post
|
||||
setBody(
|
||||
mapOf(
|
||||
"playlistId" to id.toString(),
|
||||
"newName" to name
|
||||
)
|
||||
)
|
||||
}.isOk()
|
||||
}
|
||||
|
||||
suspend fun delete(session: Session, id: UUID) = runCatchingCancellable {
|
||||
request(session, "user/playlists/delete") {
|
||||
method = HttpMethod.Post
|
||||
setBody(mapOf("playlistId" to id.toString()))
|
||||
}.isOk()
|
||||
}
|
||||
|
||||
suspend fun add(session: Session, id: UUID, videos: List<String>) = runCatchingCancellable {
|
||||
request(session, "user/playlists/add") {
|
||||
method = HttpMethod.Post
|
||||
setBody(
|
||||
mapOf(
|
||||
"playlistId" to id.toString(),
|
||||
"videoIds" to videos
|
||||
)
|
||||
)
|
||||
}.isOk()
|
||||
}
|
||||
|
||||
suspend fun remove(session: Session, id: UUID, idx: Int) = runCatchingCancellable {
|
||||
request(session, "user/playlists/remove") {
|
||||
method = HttpMethod.Post
|
||||
setBody(
|
||||
mapOf(
|
||||
"playlistId" to id.toString(),
|
||||
"index" to idx
|
||||
)
|
||||
)
|
||||
}.isOk()
|
||||
}
|
||||
|
||||
suspend fun songs(session: Session, id: UUID) = runCatchingCancellable {
|
||||
request(session, "playlists/$id").body<Playlist>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package it.hamy.piped.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Instance(
|
||||
val name: String,
|
||||
@SerialName("api_url")
|
||||
val apiBaseUrl: UrlString,
|
||||
@SerialName("locations")
|
||||
val locationsFormatted: String,
|
||||
val version: String,
|
||||
@SerialName("up_to_date")
|
||||
val upToDate: Boolean,
|
||||
@SerialName("cdn")
|
||||
val isCdn: Boolean,
|
||||
@SerialName("registered")
|
||||
val userCount: Long,
|
||||
@SerialName("last_checked")
|
||||
val lastChecked: DateTimeSeconds,
|
||||
@SerialName("cache")
|
||||
val hasCache: Boolean,
|
||||
@SerialName("s3_enabled")
|
||||
val usesS3: Boolean,
|
||||
@SerialName("image_proxy_url")
|
||||
val imageProxyBaseUrl: UrlString,
|
||||
@SerialName("registration_disabled")
|
||||
val registrationDisabled: Boolean
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
package it.hamy.piped.models
|
||||
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Serializable
|
||||
data class CreatedPlaylist(
|
||||
@SerialName("playlistId")
|
||||
val id: UUIDString
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistPreview(
|
||||
val id: UUIDString,
|
||||
val name: String,
|
||||
@SerialName("shortDescription")
|
||||
val description: String? = null,
|
||||
@SerialName("thumbnail")
|
||||
val thumbnailUrl: UrlString,
|
||||
@SerialName("videos")
|
||||
val videoCount: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Playlist(
|
||||
val name: String,
|
||||
val thumbnailUrl: UrlString,
|
||||
val description: String? = null,
|
||||
val bannerUrl: UrlString? = null,
|
||||
@SerialName("videos")
|
||||
val videoCount: Int,
|
||||
@SerialName("relatedStreams")
|
||||
val videos: List<Video>
|
||||
) {
|
||||
@Serializable
|
||||
data class Video(
|
||||
val url: String, // not a real url, why?
|
||||
val title: String,
|
||||
@SerialName("thumbnail")
|
||||
val thumbnailUrl: UrlString,
|
||||
val uploaderName: String,
|
||||
val uploaderUrl: String, // not a real url either
|
||||
@SerialName("uploaderAvatar")
|
||||
val uploaderAvatarUrl: UrlString,
|
||||
@SerialName("duration")
|
||||
val durationSeconds: Long
|
||||
) {
|
||||
val id
|
||||
get() = if (url.startsWith("/watch?v=")) url.substringAfter("/watch?v=")
|
||||
else Url(url).parameters["v"]?.firstOrNull()?.toString()
|
||||
|
||||
val uploaderId
|
||||
get() = if (uploaderUrl.startsWith("/channel/")) uploaderUrl.substringAfter("/channel/")
|
||||
else Url(uploaderUrl).pathSegments.lastOrNull()
|
||||
|
||||
val duration get() = durationSeconds.seconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package it.hamy.piped.models
|
||||
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.util.UUID
|
||||
|
||||
object UrlSerializer : KSerializer<Url> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("Url", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder) = Url(decoder.decodeString())
|
||||
override fun serialize(encoder: Encoder, value: Url) = encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
typealias UrlString = @Serializable(with = UrlSerializer::class) Url
|
||||
|
||||
object SecondLocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("DateTimeSeconds", PrimitiveKind.LONG)
|
||||
|
||||
override fun deserialize(decoder: Decoder) =
|
||||
Instant.fromEpochSeconds(decoder.decodeLong()).toLocalDateTime(TimeZone.UTC)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: LocalDateTime) =
|
||||
encoder.encodeLong(value.toInstant(TimeZone.UTC).epochSeconds)
|
||||
}
|
||||
|
||||
typealias DateTimeSeconds = @Serializable(with = SecondLocalDateTimeSerializer::class) LocalDateTime
|
||||
|
||||
object UUIDSerializer : KSerializer<UUID> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
|
||||
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
typealias UUIDString = @Serializable(with = UUIDSerializer::class) UUID
|
||||
@@ -0,0 +1,13 @@
|
||||
package it.hamy.piped.models
|
||||
|
||||
import io.ktor.http.Url
|
||||
|
||||
// marker class
|
||||
@JvmInline
|
||||
value class Session internal constructor(private val value: Pair<Url, String>) {
|
||||
val apiBaseUrl get() = value.first
|
||||
val token get() = value.second
|
||||
}
|
||||
|
||||
infix fun Url.authenticatedWith(token: String) = Session(this to token)
|
||||
infix fun String.authenticatedWith(token: String) = Url(this) authenticatedWith token
|
||||
Reference in New Issue
Block a user