0.6.0
This commit is contained in:
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