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