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

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

@@ -0,0 +1 @@
build

View 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())
}

View File

@@ -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 }

View File

@@ -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
View File

@@ -0,0 +1 @@
build

View 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())
}

View 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)
}
}

View File

@@ -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
)

View File

@@ -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
}
}
}

View File

@@ -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
)

View File

@@ -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
View File

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

View File

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

View File

@@ -0,0 +1,251 @@
package it.hamy.innertube
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.compression.brotli
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.header
import io.ktor.client.request.headers
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.serialization.kotlinx.json.json
import it.hamy.innertube.models.MusicNavigationButtonRenderer
import it.hamy.innertube.models.NavigationEndpoint
import it.hamy.innertube.models.Runs
import it.hamy.innertube.models.Thumbnail
import it.hamy.innertube.utils.ProxyPreferences
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import java.net.InetSocketAddress
import java.net.Proxy
object Innertube {
val client = HttpClient(OkHttp) {
expectSuccess = true
install(ContentNegotiation) {
@OptIn(ExperimentalSerializationApi::class)
json(
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
)
}
install(ContentEncoding) {
brotli(1.0f)
gzip(0.9f)
deflate(0.8f)
}
defaultRequest {
url(scheme = "https", host = "music.youtube.com") {
contentType(ContentType.Application.Json)
headers {
append("X-Goog-Api-Key", API_KEY)
append("x-origin", ORIGIN)
}
parameters {
append("prettyPrint", "false")
append("key", API_KEY)
}
}
}
ProxyPreferences.preference?.let {
engine {
proxy = Proxy(
it.proxyMode,
InetSocketAddress(
it.proxyHost,
it.proxyPort
)
)
}
}
}
private const val API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
private const val ORIGIN = "https://music.youtube.com"
internal const val BROWSE = "/youtubei/v1/browse"
internal const val NEXT = "/youtubei/v1/next"
internal const val PLAYER = "/youtubei/v1/player"
internal const val QUEUE = "/youtubei/v1/music/get_queue"
internal const val SEARCH = "/youtubei/v1/search"
internal const val SEARCH_SUGGESTIONS = "/youtubei/v1/music/get_search_suggestions"
internal const val MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK =
"musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)"
internal const val MUSIC_TWO_ROW_ITEM_RENDERER_MASK =
"musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)"
internal const val PLAYLIST_PANEL_VIDEO_RENDERER_MASK =
"playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)"
internal fun HttpRequestBuilder.mask(value: String = "*") =
header("X-Goog-FieldMask", value)
data class Info<T : NavigationEndpoint.Endpoint>(
val name: String?,
val endpoint: T?
) {
@Suppress("UNCHECKED_CAST")
constructor(run: Runs.Run) : this(
name = run.text,
endpoint = run.navigationEndpoint?.endpoint as T?
)
}
@JvmInline
value class SearchFilter(val value: String) {
companion object {
val Song = SearchFilter("EgWKAQIIAWoOEAMQBBAJEAoQBRAQEBU%3D")
val Video = SearchFilter("EgWKAQIQAWoOEAMQBBAJEAoQBRAQEBU%3D")
val Album = SearchFilter("EgWKAQIYAWoOEAMQBBAJEAoQBRAQEBU%3D")
val Artist = SearchFilter("EgWKAQIgAWoOEAMQBBAJEAoQBRAQEBU%3D")
val CommunityPlaylist = SearchFilter("EgeKAQQoAEABag4QAxAEEAkQChAFEBAQFQ%3D%3D")
}
}
sealed class Item {
abstract val thumbnail: Thumbnail?
abstract val key: String
}
data class SongItem(
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val album: Info<NavigationEndpoint.Endpoint.Browse>?,
val durationText: String?,
override val thumbnail: Thumbnail?
) : Item() {
override val key get() = info!!.endpoint!!.videoId!!
companion object
}
data class VideoItem(
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val viewsText: String?,
val durationText: String?,
override val thumbnail: Thumbnail?
) : Item() {
override val key get() = info!!.endpoint!!.videoId!!
val isOfficialMusicVideo: Boolean
get() = info
?.endpoint
?.watchEndpointMusicSupportedConfigs
?.watchEndpointMusicConfig
?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV"
companion object
}
data class AlbumItem(
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?,
override val thumbnail: Thumbnail?
) : Item() {
override val key get() = info!!.endpoint!!.browseId!!
companion object
}
data class ArtistItem(
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val subscribersCountText: String?,
override val thumbnail: Thumbnail?
) : Item() {
override val key get() = info!!.endpoint!!.browseId!!
companion object
}
data class PlaylistItem(
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val channel: Info<NavigationEndpoint.Endpoint.Browse>?,
val songCount: Int?,
override val thumbnail: Thumbnail?
) : Item() {
override val key get() = info!!.endpoint!!.browseId!!
companion object
}
data class ArtistPage(
val name: String?,
val description: String?,
val thumbnail: Thumbnail?,
val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?,
val radioEndpoint: NavigationEndpoint.Endpoint.Watch?,
val songs: List<SongItem>?,
val songsEndpoint: NavigationEndpoint.Endpoint.Browse?,
val albums: List<AlbumItem>?,
val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?,
val singles: List<AlbumItem>?,
val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?
)
data class PlaylistOrAlbumPage(
val title: String?,
val description: String?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?,
val thumbnail: Thumbnail?,
val url: String?,
val songsPage: ItemsPage<SongItem>?,
val otherVersions: List<AlbumItem>?,
val otherInfo: String?
)
data class NextPage(
val itemsPage: ItemsPage<SongItem>?,
val playlistId: String?,
val params: String? = null,
val playlistSetVideoId: String? = null
)
data class RelatedPage(
val songs: List<SongItem>? = null,
val playlists: List<PlaylistItem>? = null,
val albums: List<AlbumItem>? = null,
val artists: List<ArtistItem>? = null
)
data class DiscoverPage(
val newReleaseAlbums: List<AlbumItem>,
val moods: List<Mood.Item>
)
data class Mood(
val title: String,
val items: List<Item>
) {
data class Item(
val title: String,
val stripeColor: Long,
val endpoint: NavigationEndpoint.Endpoint.Browse
)
}
@Suppress("ReturnCount")
fun MusicNavigationButtonRenderer.toMood(): Mood.Item? {
return Mood.Item(
title = buttonText.runs.firstOrNull()?.text ?: return null,
stripeColor = solid?.leftStripeColor ?: return null,
endpoint = clickCommand.browseEndpoint ?: return null
)
}
data class ItemsPage<T : Item>(
val items: List<T>?,
val continuation: String?
)
}

View File

@@ -0,0 +1,65 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class BrowseResponse(
val contents: Contents?,
val header: Header?,
val microformat: Microformat?
) {
@Serializable
data class Contents(
val singleColumnBrowseResultsRenderer: Tabs?,
val sectionListRenderer: SectionListRenderer?
)
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class Header(
@JsonNames("musicVisualHeaderRenderer")
val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?,
val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?
) {
@Serializable
data class MusicDetailHeaderRenderer(
val title: Runs?,
val description: Runs?,
val subtitle: Runs?,
val secondSubtitle: Runs?,
val thumbnail: ThumbnailRenderer?
)
@Serializable
data class MusicImmersiveHeaderRenderer(
val description: Runs?,
val playButton: PlayButton?,
val startRadioButton: StartRadioButton?,
val thumbnail: ThumbnailRenderer?,
val foregroundThumbnail: ThumbnailRenderer?,
val title: Runs?
) {
@Serializable
data class PlayButton(
val buttonRenderer: ButtonRenderer?
)
@Serializable
data class StartRadioButton(
val buttonRenderer: ButtonRenderer?
)
}
}
@Serializable
data class Microformat(
val microformatDataRenderer: MicroformatDataRenderer?
) {
@Serializable
data class MicroformatDataRenderer(
val urlCanonical: String?
)
}
}

View File

@@ -0,0 +1,8 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class ButtonRenderer(
val navigationEndpoint: NavigationEndpoint?
)

View File

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

View File

@@ -0,0 +1,17 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class Continuation(
@JsonNames("nextContinuationData", "nextRadioContinuationData")
val nextContinuationData: Data?
) {
@Serializable
data class Data(
val continuation: String?
)
}

View File

@@ -0,0 +1,18 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class ContinuationResponse(
val continuationContents: ContinuationContents?
) {
@Serializable
data class ContinuationContents(
@JsonNames("musicPlaylistShelfContinuation")
val musicShelfContinuation: MusicShelfRenderer?,
val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?
)
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicCarouselShelfRenderer(
val header: Header?,
val contents: List<Content>?
) {
@Serializable
data class Content(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer? = null
)
@Serializable
data class Header(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
val musicCarouselShelfBasicHeaderRenderer: MusicCarouselShelfBasicHeaderRenderer?
) {
@Serializable
data class MusicCarouselShelfBasicHeaderRenderer(
val moreContentButton: MoreContentButton?,
val title: Runs?,
val strapline: Runs?
) {
@Serializable
data class MoreContentButton(
val buttonRenderer: ButtonRenderer?
)
}
}
}

View File

@@ -0,0 +1,26 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicNavigationButtonRenderer(
val buttonText: Runs,
val solid: Solid?,
val iconStyle: IconStyle?,
val clickCommand: NavigationEndpoint
) {
@Serializable
data class Solid(
val leftStripeColor: Long
)
@Serializable
data class IconStyle(
val icon: Icon
)
@Serializable
data class Icon(
val iconType: String
)
}

View File

@@ -0,0 +1,25 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class MusicResponsiveListItemRenderer(
val fixedColumns: List<FlexColumn>?,
val flexColumns: List<FlexColumn>,
val thumbnail: ThumbnailRenderer?,
val navigationEndpoint: NavigationEndpoint?
) {
@Serializable
data class FlexColumn(
@JsonNames("musicResponsiveListItemFixedColumnRenderer")
val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer?
) {
@Serializable
data class MusicResponsiveListItemFlexColumnRenderer(
val text: Runs?
)
}
}

View File

@@ -0,0 +1,40 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicShelfRenderer(
val bottomEndpoint: NavigationEndpoint?,
val contents: List<Content>?,
val continuations: List<Continuation>?,
val title: Runs?
) {
@Serializable
data class Content(
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?
) {
val runs: Pair<List<Runs.Run>, List<List<Runs.Run>>>
get() = musicResponsiveListItemRenderer
?.flexColumns
?.firstOrNull()
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
.orEmpty() to
musicResponsiveListItemRenderer
?.flexColumns
?.let { it.getOrNull(1) ?: it.lastOrNull() }
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.splitBySeparator()
.orEmpty()
val thumbnail: Thumbnail?
get() = musicResponsiveListItemRenderer
?.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
}
}

View File

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

View File

@@ -0,0 +1,77 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
/**
* watchPlaylistEndpoint: params, playlistId
* watchEndpoint: params, playlistId, videoId, index
* browseEndpoint: params, browseId
* searchEndpoint: params, query
*/
@Serializable
data class NavigationEndpoint(
val watchEndpoint: Endpoint.Watch?,
val watchPlaylistEndpoint: Endpoint.WatchPlaylist?,
val browseEndpoint: Endpoint.Browse?,
val searchEndpoint: Endpoint.Search?
) {
val endpoint get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint
@Serializable
sealed class Endpoint {
@Serializable
data class Watch(
val params: String? = null,
val playlistId: String? = null,
val videoId: String? = null,
val index: Int? = null,
val playlistSetVideoId: String? = null,
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null
) : Endpoint() {
@Serializable
data class WatchEndpointMusicSupportedConfigs(
val watchEndpointMusicConfig: WatchEndpointMusicConfig?
) {
@Serializable
data class WatchEndpointMusicConfig(
val musicVideoType: String?
)
}
}
@Serializable
data class WatchPlaylist(
val params: String?,
val playlistId: String?
) : Endpoint()
@Serializable
data class Browse(
val params: String? = null,
val browseId: String? = null,
val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null
) : Endpoint() {
val type: String?
get() = browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType
@Serializable
data class BrowseEndpointContextSupportedConfigs(
val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig
) {
@Serializable
data class BrowseEndpointContextMusicConfig(
val pageType: String
)
}
}
@Serializable
data class Search(
val params: String?,
val query: String
) : Endpoint()
}
}

View File

@@ -0,0 +1,87 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class NextResponse(
val contents: Contents?
) {
@Serializable
data class MusicQueueRenderer(
val content: Content?
) {
@Serializable
data class Content(
@JsonNames("playlistPanelContinuation")
val playlistPanelRenderer: PlaylistPanelRenderer?
) {
@Serializable
data class PlaylistPanelRenderer(
val contents: List<Content>?,
val continuations: List<Continuation>?
) {
@Serializable
data class Content(
val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?,
val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?
) {
@Serializable
data class AutomixPreviewVideoRenderer(
val content: Content?
) {
@Serializable
data class Content(
val automixPlaylistVideoRenderer: AutomixPlaylistVideoRenderer?
) {
@Serializable
data class AutomixPlaylistVideoRenderer(
val navigationEndpoint: NavigationEndpoint?
)
}
}
}
}
}
}
@Serializable
data class Contents(
val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer?
) {
@Serializable
data class SingleColumnMusicWatchNextResultsRenderer(
val tabbedRenderer: TabbedRenderer?
) {
@Serializable
data class TabbedRenderer(
val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer?
) {
@Serializable
data class WatchNextTabbedResultsRenderer(
val tabs: List<Tab>?
) {
@Serializable
data class Tab(
val tabRenderer: TabRenderer?
) {
@Serializable
data class TabRenderer(
val content: Content?,
val endpoint: NavigationEndpoint?,
val title: String?
) {
@Serializable
data class Content(
val musicQueueRenderer: MusicQueueRenderer?
)
}
}
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,13 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class PlaylistPanelVideoRenderer(
val title: Runs?,
val longBylineText: Runs?,
val shortBylineText: Runs?,
val lengthText: Runs?,
val navigationEndpoint: NavigationEndpoint?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail?
)

View File

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

View File

@@ -0,0 +1,13 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class SearchResponse(
val contents: Contents?
) {
@Serializable
data class Contents(
val tabbedSearchResultsRenderer: Tabs?
)
}

View File

@@ -0,0 +1,28 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class SearchSuggestionsResponse(
val contents: List<Content>?
) {
@Serializable
data class Content(
val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer?
) {
@Serializable
data class SearchSuggestionsSectionRenderer(
val contents: List<Content>?
) {
@Serializable
data class Content(
val searchSuggestionRenderer: SearchSuggestionRenderer?
) {
@Serializable
data class SearchSuggestionRenderer(
val navigationEndpoint: NavigationEndpoint?
)
}
}
}
}

View File

@@ -0,0 +1,28 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class SectionListRenderer(
val contents: List<Content>?,
val continuations: List<Continuation>?
) {
@Serializable
data class Content(
@JsonNames("musicImmersiveCarouselShelfRenderer")
val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?,
@JsonNames("musicPlaylistShelfRenderer")
val musicShelfRenderer: MusicShelfRenderer?,
val gridRenderer: GridRenderer?,
val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?
) {
@Serializable
data class MusicDescriptionShelfRenderer(
val description: Runs?
)
}
}

View File

@@ -0,0 +1,25 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class Tabs(
val tabs: List<Tab>?
) {
@Serializable
data class Tab(
val tabRenderer: TabRenderer?
) {
@Serializable
data class TabRenderer(
val content: Content?,
val title: String?,
val tabIdentifier: String?
) {
@Serializable
data class Content(
val sectionListRenderer: SectionListRenderer?
)
}
}
}

View File

@@ -0,0 +1,16 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class Thumbnail(
val url: String,
val height: Int?,
val width: Int?
) {
fun size(size: Int) = when {
url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size"
url.startsWith("https://yt3.ggpht.com") -> "$url-s$size"
else -> url
}
}

View File

@@ -0,0 +1,42 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class ThumbnailRenderer(
@JsonNames("croppedSquareThumbnailRenderer")
val musicThumbnailRenderer: MusicThumbnailRenderer?
) {
@Serializable
data class MusicThumbnailRenderer(
val thumbnail: Thumbnail?
) {
@Serializable
data class Thumbnail(
val thumbnails: List<it.hamy.innertube.models.Thumbnail>?
)
}
}
@Serializable
data class ThumbnailOverlay(
val musicItemThumbnailOverlayRenderer: MusicItemThumbnailOverlayRenderer
) {
@Serializable
data class MusicItemThumbnailOverlayRenderer(
val content: Content
) {
@Serializable
data class Content(
val musicPlayButtonRenderer: MusicPlayButtonRenderer
) {
@Serializable
data class MusicPlayButtonRenderer(
val playNavigationEndpoint: NavigationEndpoint?
)
}
}
}

View File

@@ -0,0 +1,11 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class BrowseBody(
val context: Context = Context.DefaultWeb,
val browseId: String,
val params: String? = null
)

View File

@@ -0,0 +1,10 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class ContinuationBody(
val context: Context = Context.DefaultWeb,
val continuation: String
)

View File

@@ -0,0 +1,24 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class NextBody(
val context: Context = Context.DefaultWeb,
val videoId: String?,
val isAudioOnly: Boolean = true,
val playlistId: String? = null,
val tunerSettingValue: String = "AUTOMIX_SETTING_NORMAL",
val index: Int? = null,
val params: String? = null,
val playlistSetVideoId: String? = null,
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs = WatchEndpointMusicSupportedConfigs(
musicVideoType = "MUSIC_VIDEO_TYPE_ATV"
)
) {
@Serializable
data class WatchEndpointMusicSupportedConfigs(
val musicVideoType: String
)
}

View File

@@ -0,0 +1,11 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class PlayerBody(
val context: Context = Context.DefaultAndroid,
val videoId: String,
val playlistId: String? = null
)

View File

@@ -0,0 +1,11 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class QueueBody(
val context: Context = Context.DefaultWeb,
val videoIds: List<String>? = null,
val playlistId: String? = null
)

View File

@@ -0,0 +1,11 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class SearchBody(
val context: Context = Context.DefaultWeb,
val query: String,
val params: String
)

View File

@@ -0,0 +1,10 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class SearchSuggestionsBody(
val context: Context = Context.DefaultWeb,
val input: String
)

View File

@@ -0,0 +1,34 @@
package it.hamy.innertube.requests
import io.ktor.http.Url
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.NavigationEndpoint
import it.hamy.innertube.models.bodies.BrowseBody
suspend fun Innertube.albumPage(body: BrowseBody) = playlistPage(body)?.map { album ->
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist ->
album.copy(songsPage = playlist.songsPage)
}
} ?: album
}?.map { album ->
val albumInfo = Innertube.Info(
name = album.title,
endpoint = NavigationEndpoint.Endpoint.Browse(
browseId = body.browseId,
params = body.params
)
)
album.copy(
songsPage = album.songsPage?.copy(
items = album.songsPage.items?.map { song ->
song.copy(
authors = song.authors ?: album.authors,
album = albumInfo,
thumbnail = album.thumbnail
)
}
)
)
}

View File

@@ -0,0 +1,127 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.Context
import it.hamy.innertube.models.MusicCarouselShelfRenderer
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.utils.findSectionByTitle
import it.hamy.innertube.utils.from
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
suspend fun Innertube.artistPage(body: BrowseBody) = runCatchingCancellable {
val ctx = currentCoroutineContext()
val response = client.post(BROWSE) {
setBody(body)
mask("contents,header")
}.body<BrowseResponse>()
val responseNoLang by lazy {
CoroutineScope(ctx).async(start = CoroutineStart.LAZY) {
client.post(BROWSE) {
setBody(body.copy(context = Context.DefaultWebNoLang))
mask("contents,header")
}.body<BrowseResponse>()
}
}
suspend fun findSectionByTitle(text: String) = response
.contents
?.singleColumnBrowseResultsRenderer
?.tabs
?.get(0)
?.tabRenderer
?.content
?.sectionListRenderer
?.findSectionByTitle(text) ?: responseNoLang.await()
.contents
?.singleColumnBrowseResultsRenderer
?.tabs
?.get(0)
?.tabRenderer
?.content
?.sectionListRenderer
?.findSectionByTitle(text)
val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer
val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer
val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer
Innertube.ArtistPage(
name = response
.header
?.musicImmersiveHeaderRenderer
?.title
?.text,
description = response
.header
?.musicImmersiveHeaderRenderer
?.description
?.text,
thumbnail = (
response
.header
?.musicImmersiveHeaderRenderer
?.foregroundThumbnail
?: response
.header
?.musicImmersiveHeaderRenderer
?.thumbnail
)
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.getOrNull(0),
shuffleEndpoint = response
.header
?.musicImmersiveHeaderRenderer
?.playButton
?.buttonRenderer
?.navigationEndpoint
?.watchEndpoint,
radioEndpoint = response
.header
?.musicImmersiveHeaderRenderer
?.startRadioButton
?.buttonRenderer
?.navigationEndpoint
?.watchEndpoint,
songs = songsSection
?.contents
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Innertube.SongItem::from),
songsEndpoint = songsSection
?.bottomEndpoint
?.browseEndpoint,
albums = albumsSection
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Innertube.AlbumItem::from),
albumsEndpoint = albumsSection
?.header
?.musicCarouselShelfBasicHeaderRenderer
?.moreContentButton
?.buttonRenderer
?.navigationEndpoint
?.browseEndpoint,
singles = singlesSection
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Innertube.AlbumItem::from),
singlesEndpoint = singlesSection
?.header
?.musicCarouselShelfBasicHeaderRenderer
?.moreContentButton
?.buttonRenderer
?.navigationEndpoint
?.browseEndpoint
)
}

View File

@@ -0,0 +1,68 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.MusicTwoRowItemRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.utils.from
suspend fun Innertube.browse(body: BrowseBody) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
}.body<BrowseResponse>()
BrowseResult(
title = response.header?.musicImmersiveHeaderRenderer?.title?.text ?: response.header
?.musicDetailHeaderRenderer?.title?.text,
items = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()
?.tabRenderer?.content?.sectionListRenderer?.contents?.mapNotNull { content ->
when {
content.gridRenderer != null -> BrowseResult.Item(
title = content.gridRenderer.header?.gridHeaderRenderer?.title?.runs
?.firstOrNull()?.text ?: return@mapNotNull null,
items = content.gridRenderer.items?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() }
.orEmpty()
)
content.musicCarouselShelfRenderer != null -> BrowseResult.Item(
title = content
.musicCarouselShelfRenderer
.header
?.musicCarouselShelfBasicHeaderRenderer
?.title
?.runs
?.firstOrNull()
?.text ?: return@mapNotNull null,
items = content
.musicCarouselShelfRenderer
.contents
?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() }
.orEmpty()
)
else -> null
}
}.orEmpty()
)
}
data class BrowseResult(
val title: String?,
val items: List<Item>
) {
data class Item(
val title: String,
val items: List<Innertube.Item>
)
}
fun MusicTwoRowItemRenderer.toItem() = when {
isAlbum -> Innertube.AlbumItem.from(this)
isPlaylist -> Innertube.PlaylistItem.from(this)
isArtist -> Innertube.ArtistItem.from(this)
else -> null
}

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.NextResponse
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.bodies.NextBody
suspend fun Innertube.lyrics(body: NextBody) = runCatchingCancellable {
val nextResponse = client.post(NEXT) {
setBody(body)
@Suppress("all")
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)")
}.body<NextResponse>()
val browseId = nextResponse
.contents
?.singleColumnMusicWatchNextResultsRenderer
?.tabbedRenderer
?.watchNextTabbedResultsRenderer
?.tabs
?.getOrNull(1)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
?: return@runCatchingCancellable null
val response = client.post(BROWSE) {
setBody(BrowseBody(browseId = browseId))
mask("contents.sectionListRenderer.contents.musicDescriptionShelfRenderer.description")
}.body<BrowseResponse>()
response.contents
?.sectionListRenderer
?.contents
?.firstOrNull()
?.musicDescriptionShelfRenderer
?.description
?.text
}

View File

@@ -0,0 +1,90 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.ContinuationResponse
import it.hamy.innertube.models.NextResponse
import it.hamy.innertube.models.bodies.ContinuationBody
import it.hamy.innertube.models.bodies.NextBody
import it.hamy.innertube.utils.from
suspend fun Innertube.nextPage(body: NextBody): Result<Innertube.NextPage>? =
runCatchingCancellable {
val response = client.post(NEXT) {
setBody(body)
@Suppress("all")
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$PLAYLIST_PANEL_VIDEO_RENDERER_MASK))")
}.body<NextResponse>()
val tabs = response
.contents
?.singleColumnMusicWatchNextResultsRenderer
?.tabbedRenderer
?.watchNextTabbedResultsRenderer
?.tabs
val playlistPanelRenderer = tabs
?.getOrNull(0)
?.tabRenderer
?.content
?.musicQueueRenderer
?.content
?.playlistPanelRenderer
if (body.playlistId == null) {
val endpoint = playlistPanelRenderer
?.contents
?.lastOrNull()
?.automixPreviewVideoRenderer
?.content
?.automixPlaylistVideoRenderer
?.navigationEndpoint
?.watchPlaylistEndpoint
if (endpoint != null) return nextPage(
body.copy(
playlistId = endpoint.playlistId,
params = endpoint.params
)
)
}
Innertube.NextPage(
playlistId = body.playlistId,
playlistSetVideoId = body.playlistSetVideoId,
params = body.params,
itemsPage = playlistPanelRenderer
?.toSongsPage()
)
}
suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingCancellable {
val response = client.post(NEXT) {
setBody(body)
@Suppress("all")
mask("continuationContents.playlistPanelContinuation(continuations,contents.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK)")
}.body<ContinuationResponse>()
response
.continuationContents
?.playlistPanelContinuation
?.toSongsPage()
}
private fun NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?.toSongsPage() =
Innertube.ItemsPage(
items = this
?.contents
?.mapNotNull(
NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content
::playlistPanelVideoRenderer
)?.mapNotNull(Innertube.SongItem::from),
continuation = this
?.continuations
?.firstOrNull()
?.nextContinuationData
?.continuation
)

View File

@@ -0,0 +1,67 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.Context
import it.hamy.innertube.models.PlayerResponse
import it.hamy.innertube.models.bodies.PlayerBody
import kotlinx.serialization.Serializable
suspend fun Innertube.player(body: PlayerBody) = runCatchingCancellable {
val response = client.post(PLAYER) {
setBody(body)
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId")
}.body<PlayerResponse>()
if (response.playabilityStatus?.status == "OK") {
response
} else {
@Serializable
data class AudioStream(
val url: String,
val bitrate: Long
)
@Serializable
data class PipedResponse(
val audioStreams: List<AudioStream>
)
val safePlayerResponse = client.post(PLAYER) {
setBody(
body.copy(
context = Context.DefaultAgeRestrictionBypass.copy(
thirdParty = Context.ThirdParty(
embedUrl = "https://www.youtube.com/watch?v=${body.videoId}"
)
)
)
)
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId")
}.body<PlayerResponse>()
if (safePlayerResponse.playabilityStatus?.status != "OK") {
return@runCatchingCancellable response
}
val audioStreams = client.get("https://pipedapi.adminforge.de/streams/${body.videoId}") {
contentType(ContentType.Application.Json)
}.body<PipedResponse>().audioStreams
safePlayerResponse.copy(
streamingData = safePlayerResponse.streamingData?.copy(
adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat ->
adaptiveFormat.copy(
url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url
)
}
)
)
}
}

View File

@@ -0,0 +1,110 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.ContinuationResponse
import it.hamy.innertube.models.MusicCarouselShelfRenderer
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.bodies.ContinuationBody
import it.hamy.innertube.utils.from
suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
body.context.apply()
}.body<BrowseResponse>()
val musicDetailHeaderRenderer = response
.header
?.musicDetailHeaderRenderer
val sectionListRendererContents = response
.contents
?.singleColumnBrowseResultsRenderer
?.tabs
?.firstOrNull()
?.tabRenderer
?.content
?.sectionListRenderer
?.contents
val musicShelfRenderer = sectionListRendererContents
?.firstOrNull()
?.musicShelfRenderer
val musicCarouselShelfRenderer = sectionListRendererContents
?.getOrNull(1)
?.musicCarouselShelfRenderer
Innertube.PlaylistOrAlbumPage(
title = musicDetailHeaderRenderer
?.title
?.text,
description = musicDetailHeaderRenderer
?.description
?.text,
thumbnail = musicDetailHeaderRenderer
?.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull(),
authors = musicDetailHeaderRenderer
?.subtitle
?.splitBySeparator()
?.getOrNull(1)
?.map(Innertube::Info),
year = musicDetailHeaderRenderer
?.subtitle
?.splitBySeparator()
?.getOrNull(2)
?.firstOrNull()
?.text,
url = response
.microformat
?.microformatDataRenderer
?.urlCanonical,
songsPage = musicShelfRenderer
?.toSongsPage(),
otherVersions = musicCarouselShelfRenderer
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Innertube.AlbumItem::from),
otherInfo = musicDetailHeaderRenderer
?.secondSubtitle
?.text
)
}
suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
parameter("continuation", body.continuation)
parameter("ctoken", body.continuation)
parameter("type", "next")
body.context.apply()
}.body<ContinuationResponse>()
response
.continuationContents
?.musicShelfContinuation
?.toSongsPage()
}
private fun MusicShelfRenderer?.toSongsPage() = Innertube.ItemsPage(
items = this
?.contents
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Innertube.SongItem::from),
continuation = this
?.continuations
?.firstOrNull()
?.nextContinuationData
?.continuation
)

View File

@@ -0,0 +1,29 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.GetQueueResponse
import it.hamy.innertube.models.bodies.QueueBody
import it.hamy.innertube.utils.from
suspend fun Innertube.queue(body: QueueBody) = runCatchingCancellable {
val response = client.post(QUEUE) {
setBody(body)
mask("queueDatas.content.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK")
}.body<GetQueueResponse>()
response
.queueData
?.mapNotNull { queueData ->
queueData
.content
?.playlistPanelVideoRenderer
?.let(Innertube.SongItem::from)
}
}
suspend fun Innertube.song(videoId: String): Result<Innertube.SongItem?>? =
queue(QueueBody(videoIds = listOf(videoId)))?.map { it?.firstOrNull() }

View File

@@ -0,0 +1,84 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.Context
import it.hamy.innertube.models.MusicCarouselShelfRenderer
import it.hamy.innertube.models.NextResponse
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.bodies.NextBody
import it.hamy.innertube.utils.findSectionByStrapline
import it.hamy.innertube.utils.findSectionByTitle
import it.hamy.innertube.utils.from
suspend fun Innertube.relatedPage(body: NextBody) = runCatchingCancellable {
val nextResponse = client.post(NEXT) {
setBody(body.copy(context = Context.DefaultWebNoLang))
@Suppress("all")
mask(
"contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)"
)
}.body<NextResponse>()
val browseId = nextResponse
.contents
?.singleColumnMusicWatchNextResultsRenderer
?.tabbedRenderer
?.watchNextTabbedResultsRenderer
?.tabs
?.getOrNull(2)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
?: return@runCatchingCancellable null
val response = client.post(BROWSE) {
setBody(
BrowseBody(
browseId = browseId,
context = Context.DefaultWebNoLang
)
)
@Suppress("all")
mask(
"contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK,$MUSIC_TWO_ROW_ITEM_RENDERER_MASK))"
)
}.body<BrowseResponse>()
val sectionListRenderer = response
.contents
?.sectionListRenderer
Innertube.RelatedPage(
songs = sectionListRenderer
?.findSectionByTitle("You might also like")
?.musicCarouselShelfRenderer
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Innertube.SongItem::from),
playlists = sectionListRenderer
?.findSectionByTitle("Recommended playlists")
?.musicCarouselShelfRenderer
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Innertube.PlaylistItem::from)
?.sortedByDescending { it.channel?.name == "YouTube Music" },
albums = sectionListRenderer
?.findSectionByStrapline("MORE FROM")
?.musicCarouselShelfRenderer
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Innertube.AlbumItem::from),
artists = sectionListRenderer
?.findSectionByTitle("Similar artists")
?.musicCarouselShelfRenderer
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Innertube.ArtistItem::from)
)
}

View File

@@ -0,0 +1,65 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.ContinuationResponse
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.SearchResponse
import it.hamy.innertube.models.bodies.ContinuationBody
import it.hamy.innertube.models.bodies.SearchBody
suspend fun <T : Innertube.Item> Innertube.searchPage(
body: SearchBody,
fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?
) = runCatchingCancellable {
val response = client.post(SEARCH) {
setBody(body)
@Suppress("all")
mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)")
}.body<SearchResponse>()
response
.contents
?.tabbedSearchResultsRenderer
?.tabs
?.firstOrNull()
?.tabRenderer
?.content
?.sectionListRenderer
?.contents
?.lastOrNull()
?.musicShelfRenderer
?.toItemsPage(fromMusicShelfRendererContent)
}
suspend fun <T : Innertube.Item> Innertube.searchPage(
body: ContinuationBody,
fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?
) = runCatchingCancellable {
val response = client.post(SEARCH) {
setBody(body)
@Suppress("all")
mask("continuationContents.musicShelfContinuation(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)")
}.body<ContinuationResponse>()
response
.continuationContents
?.musicShelfContinuation
?.toItemsPage(fromMusicShelfRendererContent)
}
private fun <T : Innertube.Item> MusicShelfRenderer?.toItemsPage(
mapper: (MusicShelfRenderer.Content) -> T?
) = Innertube.ItemsPage(
items = this
?.contents
?.mapNotNull(mapper),
continuation = this
?.continuations
?.firstOrNull()
?.nextContinuationData
?.continuation
)

View File

@@ -0,0 +1,30 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.SearchSuggestionsResponse
import it.hamy.innertube.models.bodies.SearchSuggestionsBody
suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingCancellable {
val response = client.post(SEARCH_SUGGESTIONS) {
setBody(body)
@Suppress("all")
mask("contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query")
}.body<SearchSuggestionsResponse>()
response
.contents
?.firstOrNull()
?.searchSuggestionsSectionRenderer
?.contents
?.mapNotNull { content ->
content
.searchSuggestionRenderer
?.navigationEndpoint
?.searchEndpoint
?.query
}
}

View File

@@ -0,0 +1,53 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.MusicResponsiveListItemRenderer
import it.hamy.innertube.models.NavigationEndpoint
fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) =
Innertube.SongItem(
info = renderer
.flexColumns
.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.let {
if (it.navigationEndpoint?.endpoint is NavigationEndpoint.Endpoint.Watch) Innertube.Info(
name = it.text,
endpoint = it.navigationEndpoint.endpoint as NavigationEndpoint.Endpoint.Watch
) else null
},
authors = renderer
.flexColumns
.getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.map { Innertube.Info(name = it.text, endpoint = it.navigationEndpoint?.endpoint) }
?.filterIsInstance<Innertube.Info<NavigationEndpoint.Endpoint.Browse>>()
?.takeIf(List<Any>::isNotEmpty),
durationText = renderer
.fixedColumns
?.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.text,
album = renderer
.flexColumns
.getOrNull(2)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.firstOrNull()
?.let(Innertube::Info),
thumbnail = renderer
.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
).takeIf { it.info?.endpoint?.videoId != null }

View File

@@ -0,0 +1,143 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.NavigationEndpoint
fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.SongItem? {
val (mainRuns, otherRuns) = content.runs
// Possible configurations:
// "song" • author(s) • album • duration
// "song" • author(s) • duration
// author(s) • album • duration
// author(s) • duration
val album: Innertube.Info<NavigationEndpoint.Endpoint.Browse>? = otherRuns
.getOrNull(otherRuns.lastIndex - 1)
?.firstOrNull()
?.takeIf { run ->
run
.navigationEndpoint
?.browseEndpoint
?.type == "MUSIC_PAGE_TYPE_ALBUM"
}
?.let(Innertube::Info)
return Innertube.SongItem(
info = mainRuns
.firstOrNull()
?.let(Innertube::Info),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2)
?.map(Innertube::Info),
album = album,
durationText = otherRuns
.lastOrNull()
?.firstOrNull()
?.text
?.takeIf { ':' in it }
?: otherRuns
.getOrNull(otherRuns.size - 2)
?.firstOrNull()
?.text,
thumbnail = content.thumbnail
).takeIf { it.info?.endpoint?.videoId != null }
}
fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.VideoItem? {
val (mainRuns, otherRuns) = content.runs
return runCatching {
Innertube.VideoItem(
info = mainRuns
.firstOrNull()
?.let(Innertube::Info),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - 2)
?.map(Innertube::Info),
viewsText = otherRuns
.getOrNull(otherRuns.lastIndex - 1)
?.firstOrNull()
?.text,
durationText = otherRuns
.getOrNull(otherRuns.lastIndex)
?.firstOrNull()
?.text,
thumbnail = content.thumbnail
).takeIf { it.info?.endpoint?.videoId != null }
}.getOrNull()
}
fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.AlbumItem? {
val (mainRuns, otherRuns) = content.runs
return Innertube.AlbumItem(
info = Innertube.Info(
name = mainRuns
.firstOrNull()
?.text,
endpoint = content
.musicResponsiveListItemRenderer
?.navigationEndpoint
?.browseEndpoint
),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - 1)
?.map(Innertube::Info),
year = otherRuns
.getOrNull(otherRuns.lastIndex)
?.firstOrNull()
?.text,
thumbnail = content.thumbnail
).takeIf { it.info?.endpoint?.browseId != null }
}
fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.ArtistItem? {
val (mainRuns, otherRuns) = content.runs
return Innertube.ArtistItem(
info = Innertube.Info(
name = mainRuns
.firstOrNull()
?.text,
endpoint = content
.musicResponsiveListItemRenderer
?.navigationEndpoint
?.browseEndpoint
),
subscribersCountText = otherRuns
.lastOrNull()
?.last()
?.text,
thumbnail = content.thumbnail
).takeIf { it.info?.endpoint?.browseId != null }
}
fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.PlaylistItem? {
val (mainRuns, otherRuns) = content.runs
return Innertube.PlaylistItem(
info = Innertube.Info(
name = mainRuns
.firstOrNull()
?.text,
endpoint = content
.musicResponsiveListItemRenderer
?.navigationEndpoint
?.browseEndpoint
),
channel = otherRuns
.firstOrNull()
?.firstOrNull()
?.let(Innertube::Info),
songCount = otherRuns
.lastOrNull()
?.firstOrNull()
?.text
?.split(' ')
?.firstOrNull()
?.toIntOrNull(),
thumbnail = content.thumbnail
).takeIf { it.info?.endpoint?.browseId != null }
}

View File

@@ -0,0 +1,71 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.MusicTwoRowItemRenderer
fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.AlbumItem(
info = renderer
.title
?.runs
?.firstOrNull()
?.let(Innertube::Info),
authors = null,
year = renderer
.subtitle
?.runs
?.lastOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.ArtistItem(
info = renderer
.title
?.runs
?.firstOrNull()
?.let(Innertube::Info),
subscribersCountText = renderer
.subtitle
?.runs
?.firstOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer) =
Innertube.PlaylistItem(
info = renderer
.title
?.runs
?.firstOrNull()
?.let(Innertube::Info),
channel = renderer
.subtitle
?.runs
?.getOrNull(2)
?.let(Innertube::Info),
songCount = renderer
.subtitle
?.runs
?.getOrNull(4)
?.text
?.split(' ')
?.firstOrNull()
?.toIntOrNull(),
thumbnail = renderer
.thumbnailRenderer
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }

View File

@@ -0,0 +1,33 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.PlaylistPanelVideoRenderer
fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer) = Innertube.SongItem(
info = Innertube.Info(
name = renderer
.title
?.text,
endpoint = renderer
.navigationEndpoint
?.watchEndpoint
),
authors = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(0)
?.map(Innertube::Info),
album = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(1)
?.getOrNull(0)
?.let(Innertube::Info),
thumbnail = renderer
.thumbnail
?.thumbnails
?.getOrNull(0),
durationText = renderer
.lengthText
?.text
).takeIf { it.info?.endpoint?.videoId != null }

View File

@@ -0,0 +1,13 @@
package it.hamy.innertube.utils
import java.net.Proxy
object ProxyPreferences {
var preference: ProxyPreferenceItem? = null
}
data class ProxyPreferenceItem(
var proxyHost: String,
var proxyPort: Int,
var proxyMode: Proxy.Type
)

View File

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

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

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

View 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())
}

View 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("&apos;", "'")
}
}

View File

@@ -0,0 +1,8 @@
package it.hamy.kugou.models
import kotlinx.serialization.Serializable
@Serializable
internal class DownloadLyricsResponse(
val content: String
)

View File

@@ -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
)
}

View File

@@ -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
View File

@@ -0,0 +1 @@
build

View 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())
}

View 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()
}
}

View File

@@ -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
View File

@@ -0,0 +1 @@
build

View 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())
}

View 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>()
}
}
}

View File

@@ -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
)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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