=
+ { throw NotImplementedError(EXTRACT_PERMISSIONS_NOT_DEFINED) }
+
+ /**
+ * Define the Global permission to ignore route specific
+ * permission requirements when attached to the [Principal].
+ */
+ fun global(permission: P) {
+ globalPermission = permission
+ }
+
+ /**
+ * Define how to extract the user's permission sent from
+ * the [Principal] instance.
+ *
+ * Note: This should be a fast value mapping function,
+ * do not read from an expensive data source.
+ */
+ fun
extract(body: (Principal) -> Set
) {
+ extractPermissions = body
+ }
+ }
+
+ fun
interceptPipeline(
+ pipeline: ApplicationCallPipeline,
+ any: Set
? = null,
+ all: Set
? = null,
+ none: Set
? = null
+ ) {
+ pipeline.insertPhaseAfter(ApplicationCallPipeline.Features, Authentication.ChallengePhase)
+ pipeline.insertPhaseAfter(Authentication.ChallengePhase, AuthorizationPhase)
+
+ pipeline.intercept(AuthorizationPhase) {
+ val principal = checkNotNull(call.authentication.principal()) {
+ PRINCIPAL_OBJECT_MISSING
+ }
+ val activePermissions = configuration.extractPermissions(principal)
+ configuration.globalPermission?.let {
+ if (activePermissions.contains(it)) {
+ return@intercept
+ }
+ }
+ val denyReasons = mutableListOf()
+ all?.let {
+ val missing = all - activePermissions
+ if (missing.isNotEmpty()) {
+ denyReasons += PRINCIPAL_PERMISSIONS_MISSING_ALL.format(principal, missing.joinToString(" and "))
+ }
+ }
+ any?.let {
+ if (any.none { it in activePermissions }) {
+ denyReasons += PRINCIPAL_PERMISSIONS_MISSING_ANY.format(principal, any.joinToString(" or "))
+ }
+ }
+ none?.let {
+ if (none.any { it in activePermissions }) {
+ val permissions = none.intersect(activePermissions).joinToString(" and ")
+ denyReasons += PRINCIPAL_PERMISSIONS_MATCHED_EXCLUDE.format(principal, permissions)
+ }
+ }
+ if (denyReasons.isNotEmpty()) {
+ val message = denyReasons.joinToString(". ")
+ call.application.log.warn("Authorization failed for ${call.request.path()}. $message")
+ call.respond(Forbidden)
+ finish()
+ }
+ }
+ }
+
+
+ companion object Feature :
+ ApplicationFeature {
+ override val key = AttributeKey("PermissionAuthorization")
+
+ val AuthorizationPhase = PipelinePhase("PermissionAuthorization")
+
+ override fun install(
+ pipeline: ApplicationCallPipeline,
+ configure: Configuration.() -> Unit
+ ): PermissionAuthorization {
+ return PermissionAuthorization(Configuration().also(configure))
+ }
+ }
+}
diff --git a/ktor-permissions/src/main/kotlin/RoutePermissionApi.kt b/ktor-permissions/src/main/kotlin/RoutePermissionApi.kt
new file mode 100644
index 00000000..ea4e6692
--- /dev/null
+++ b/ktor-permissions/src/main/kotlin/RoutePermissionApi.kt
@@ -0,0 +1,80 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.ktor.permissions
+
+import io.ktor.application.*
+import io.ktor.routing.*
+
+/**
+ * Routes defined in [build] will only be invoked when the
+ * [io.ktor.auth.Principal] contains [permission].
+ *
+ * If a global permission is defined, [io.ktor.auth.Principal]s with
+ * that permission will ignore the requirement.
+ */
+fun Route.withPermission(permission: P, build: Route.() -> Unit) =
+ authorizedRoute(all = setOf(permission), build = build)
+
+/**
+ * Routes defined in [build] will only be invoked when the
+ * [io.ktor.auth.Principal] contains all of [permissions].
+ *
+ * If a global permission is defined, [io.ktor.auth.Principal]s with
+ * that permission will ignore the requirement.
+ */
+fun
Route.withAllPermissions(vararg permissions: P, build: Route.() -> Unit) =
+ authorizedRoute(all = permissions.toSet(), build = build)
+
+/**
+ * Routes defined in [build] will only be invoked when the
+ * [io.ktor.auth.Principal] contains any of [permissions].
+ *
+ * If a global permission is defined, [io.ktor.auth.Principal]s with
+ * that permission will ignore the requirement.
+ */
+fun
Route.withAnyPermission(vararg permissions: P, build: Route.() -> Unit) =
+ authorizedRoute(any = permissions.toSet(), build = build)
+
+/**
+ * Routes defined in [build] will only be invoked when the
+ * [io.ktor.auth.Principal] does not contain any of [permissions].
+ *
+ * If a global permission is defined, [io.ktor.auth.Principal]s with
+ * that permission will ignore the requirement.
+ */
+fun
Route.withoutPermissions(vararg permissions: P, build: Route.() -> Unit) =
+ authorizedRoute(none = permissions.toSet(), build = build)
+
+private fun
Route.authorizedRoute(
+ any: Set
? = null,
+ all: Set
? = null,
+ none: Set
? = null,
+ build: Route.() -> Unit
+): Route {
+ val description = listOfNotNull(
+ any?.let { "anyOf (${any.joinToString(" ")})" },
+ all?.let { "allOf (${all.joinToString(" ")})" },
+ none?.let { "noneOf (${none.joinToString(" ")})" }
+ ).joinToString(",")
+ return createChild(AuthorizedRouteSelector(description)).also { route ->
+ application
+ .feature(PermissionAuthorization)
+ .interceptPipeline(route, any, all, none)
+ route.build()
+ }
+}
diff --git a/ktor-permissions/src/test/kotlin/PermissionTests.kt b/ktor-permissions/src/test/kotlin/PermissionTests.kt
new file mode 100644
index 00000000..dcedb915
--- /dev/null
+++ b/ktor-permissions/src/test/kotlin/PermissionTests.kt
@@ -0,0 +1,218 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.ktor.permissions
+
+import drewcarlson.ktor.permissions.Permission.A
+import drewcarlson.ktor.permissions.Permission.B
+import drewcarlson.ktor.permissions.Permission.C
+import drewcarlson.ktor.permissions.Permission.Z
+import io.ktor.auth.Principal
+import io.ktor.http.HttpStatusCode.Companion.Forbidden
+import io.ktor.http.HttpStatusCode.Companion.OK
+import kotlinx.serialization.Serializable
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+enum class Permission {
+ A, B, C, Z
+}
+
+@Serializable
+data class UserSession(
+ val userId: String,
+ val permissions: Set,
+) : Principal
+
+class PermissionTests {
+
+ @Test
+ fun testUndefinedGlobalHasNoRestrictedAccess() {
+ runPermissionTest(setGlobal = false) {
+ val token = tokenWith(Z)
+
+ Permission.values()
+ .filter { it != Z }
+ .fold(listOf()) { prev, perm ->
+ val set = (prev + perm).toSet().joinToString("") { it.name }
+ assertEquals(Forbidden, statusFor("/all/$set", token))
+ assertEquals(Forbidden, statusFor("/any/$set", token))
+ assertEquals(Forbidden, statusFor("/$perm", token))
+ prev + perm
+ }
+ }
+ }
+
+ @Test
+ fun testGlobalAllowsBypassesAllRules() {
+ runPermissionTest(setGlobal = true) {
+ val token = tokenWith(Z)
+
+ Permission.values()
+ .fold(listOf()) { prev, perm ->
+ val set = (prev + perm).toSet().joinToString("") { it.name }
+ assertEquals(OK, statusFor("/all/$set", token))
+ assertEquals(OK, statusFor("/any/$set", token))
+ assertEquals(OK, statusFor("/$perm", token))
+ assertEquals(OK, statusFor("/without/$perm", token))
+ prev + perm
+ }
+ }
+ }
+
+ @Test
+ fun testWithPermission() {
+ runPermissionTest(setGlobal = true) {
+ val tokenA = tokenWith(A)
+
+ assertEquals(OK, statusFor("/A", tokenA))
+ assertEquals(Forbidden, statusFor("/B", tokenA))
+ assertEquals(Forbidden, statusFor("/C", tokenA))
+ assertEquals(Forbidden, statusFor("/Z", tokenA))
+
+ val tokenB = tokenWith(B)
+
+ assertEquals(OK, statusFor("/B", tokenB))
+ assertEquals(Forbidden, statusFor("/A", tokenB))
+ assertEquals(Forbidden, statusFor("/C", tokenB))
+ assertEquals(Forbidden, statusFor("/Z", tokenB))
+
+ val tokenC = tokenWith(C)
+
+ assertEquals(OK, statusFor("/C", tokenC))
+ assertEquals(Forbidden, statusFor("/A", tokenC))
+ assertEquals(Forbidden, statusFor("/B", tokenC))
+ assertEquals(Forbidden, statusFor("/Z", tokenC))
+ }
+ }
+
+ @Test
+ fun testWithAllPermission() {
+ runPermissionTest(setGlobal = true) {
+ val tokenA = tokenWith(A)
+
+ assertEquals(OK, statusFor("/all/A", tokenA))
+ assertEquals(Forbidden, statusFor("/all/AB", tokenA))
+ assertEquals(Forbidden, statusFor("/all/ABC", tokenA))
+ assertEquals(Forbidden, statusFor("/all/ABCZ", tokenA))
+
+ val tokenB = tokenWith(B)
+
+ assertEquals(OK, statusFor("/all/B", tokenB))
+ assertEquals(Forbidden, statusFor("/all/A", tokenB))
+ assertEquals(Forbidden, statusFor("/all/AB", tokenB))
+ assertEquals(Forbidden, statusFor("/all/ABC", tokenB))
+ assertEquals(Forbidden, statusFor("/all/ABCZ", tokenB))
+
+ val tokenC = tokenWith(C)
+
+ assertEquals(OK, statusFor("/all/C", tokenC))
+ assertEquals(Forbidden, statusFor("/all/A", tokenC))
+ assertEquals(Forbidden, statusFor("/all/AB", tokenC))
+ assertEquals(Forbidden, statusFor("/all/ABC", tokenC))
+ assertEquals(Forbidden, statusFor("/all/ABCZ", tokenC))
+
+ val tokenAB = tokenWith(A, B)
+
+ assertEquals(OK, statusFor("/all/A", tokenAB))
+ assertEquals(OK, statusFor("/all/B", tokenAB))
+ assertEquals(OK, statusFor("/all/AB", tokenAB))
+ assertEquals(Forbidden, statusFor("/all/C", tokenAB))
+ assertEquals(Forbidden, statusFor("/all/ABC", tokenAB))
+ assertEquals(Forbidden, statusFor("/all/ABCZ", tokenAB))
+ }
+ }
+
+ @Test
+ fun testWithAnyPermission() {
+ runPermissionTest(setGlobal = true) {
+ val tokenA = tokenWith(A)
+
+ assertEquals(OK, statusFor("/any/A", tokenA))
+ assertEquals(OK, statusFor("/any/AB", tokenA))
+ assertEquals(OK, statusFor("/any/ABC", tokenA))
+ assertEquals(Forbidden, statusFor("/any/B", tokenA))
+ assertEquals(Forbidden, statusFor("/any/C", tokenA))
+ assertEquals(Forbidden, statusFor("/any/Z", tokenA))
+
+ val tokenB = tokenWith(B)
+
+ assertEquals(OK, statusFor("/any/ABC", tokenB))
+ assertEquals(OK, statusFor("/any/AB", tokenB))
+ assertEquals(OK, statusFor("/any/B", tokenB))
+ assertEquals(Forbidden, statusFor("/any/C", tokenB))
+ assertEquals(Forbidden, statusFor("/any/A", tokenB))
+ assertEquals(Forbidden, statusFor("/any/Z", tokenB))
+
+ val tokenC = tokenWith(C)
+
+ assertEquals(OK, statusFor("/any/C", tokenC))
+ assertEquals(OK, statusFor("/any/ABC", tokenC))
+ assertEquals(Forbidden, statusFor("/any/A", tokenC))
+ assertEquals(Forbidden, statusFor("/any/AB", tokenC))
+ assertEquals(Forbidden, statusFor("/any/B", tokenC))
+ assertEquals(Forbidden, statusFor("/any/Z", tokenC))
+
+ val tokenBC = tokenWith(B, C)
+
+ assertEquals(OK, statusFor("/any/C", tokenBC))
+ assertEquals(OK, statusFor("/any/ABC", tokenBC))
+ assertEquals(Forbidden, statusFor("/any/A", tokenBC))
+ assertEquals(OK, statusFor("/any/AB", tokenBC))
+ assertEquals(OK, statusFor("/any/B", tokenBC))
+ assertEquals(Forbidden, statusFor("/any/Z", tokenC))
+ }
+ }
+
+ @Test
+ fun testWithoutPermission() {
+ runPermissionTest(setGlobal = true) {
+ val tokenA = tokenWith(A)
+
+ assertEquals(Forbidden, statusFor("/without/A", tokenA))
+ assertEquals(Forbidden, statusFor("/without/AB", tokenA))
+ assertEquals(Forbidden, statusFor("/without/ABC", tokenA))
+ assertEquals(OK, statusFor("/without/B", tokenA))
+ assertEquals(OK, statusFor("/without/C", tokenA))
+ assertEquals(OK, statusFor("/without/Z", tokenA))
+
+ val tokenB = tokenWith(B)
+
+ assertEquals(Forbidden, statusFor("/without/ABC", tokenB))
+ assertEquals(Forbidden, statusFor("/without/AB", tokenB))
+ assertEquals(Forbidden, statusFor("/without/B", tokenB))
+ assertEquals(OK, statusFor("/without/C", tokenB))
+ assertEquals(OK, statusFor("/without/A", tokenB))
+
+ val tokenC = tokenWith(C)
+
+ assertEquals(Forbidden, statusFor("/without/C", tokenC))
+ assertEquals(Forbidden, statusFor("/without/ABC", tokenC))
+ assertEquals(OK, statusFor("/without/A", tokenC))
+ assertEquals(OK, statusFor("/without/AB", tokenC))
+ assertEquals(OK, statusFor("/without/B", tokenC))
+
+ val tokenAB = tokenWith(A,B)
+
+ assertEquals(OK, statusFor("/without/C", tokenAB))
+ assertEquals(Forbidden, statusFor("/without/ABC", tokenAB))
+ assertEquals(Forbidden, statusFor("/without/A", tokenAB))
+ assertEquals(Forbidden, statusFor("/without/AB", tokenAB))
+ assertEquals(Forbidden, statusFor("/without/B", tokenAB))
+ }
+ }
+}
diff --git a/ktor-permissions/src/test/kotlin/runPermissionTest.kt b/ktor-permissions/src/test/kotlin/runPermissionTest.kt
new file mode 100644
index 00000000..694d0526
--- /dev/null
+++ b/ktor-permissions/src/test/kotlin/runPermissionTest.kt
@@ -0,0 +1,148 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.ktor.permissions
+
+import io.ktor.application.call
+import io.ktor.application.install
+import io.ktor.auth.Authentication
+import io.ktor.auth.authenticate
+import io.ktor.auth.session
+import io.ktor.features.ContentNegotiation
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.request.receiveOrNull
+import io.ktor.response.respond
+import io.ktor.routing.*
+import io.ktor.serialization.DefaultJson
+import io.ktor.serialization.json
+import io.ktor.server.testing.*
+import io.ktor.sessions.SessionStorageMemory
+import io.ktor.sessions.Sessions
+import io.ktor.sessions.getOrSet
+import io.ktor.sessions.header
+import io.ktor.sessions.sessions
+import kotlinx.serialization.encodeToString
+import java.util.Base64
+import kotlin.random.Random
+
+private const val TOKEN = "TOKEN"
+
+fun TestApplicationEngine.tokenWith(vararg permissions: Permission): String {
+ return handleRequest(HttpMethod.Post, "/token") {
+ addHeader("Content-Type", ContentType.Application.Json.toString())
+ setBody(DefaultJson.encodeToString(permissions))
+ }.response.headers[TOKEN]!!
+}
+
+fun TestApplicationEngine.statusFor(
+ uri: String,
+ token: String,
+): HttpStatusCode {
+ return handleRequest(HttpMethod.Get, uri) {
+ addHeader(TOKEN, token)
+ }.response.status()!!
+}
+
+fun runPermissionTest(
+ setGlobal: Boolean,
+ test: TestApplicationEngine.() -> Unit,
+) {
+ withTestApplication({
+ install(Authentication) {
+ session {
+ challenge { context.respond(HttpStatusCode.Unauthorized) }
+ validate { it }
+ }
+ }
+
+ install(Sessions) {
+ header(TOKEN, SessionStorageMemory()) {
+ identity { Base64.getEncoder().encodeToString(Random.nextBytes(12)) }
+ }
+ }
+
+ install(PermissionAuthorization) {
+ if (setGlobal) {
+ global(Permission.Z)
+ }
+ extract { (it as UserSession).permissions }
+ }
+
+ install(ContentNegotiation) {
+ json()
+ }
+
+ routing {
+ post("/token") {
+ val permissions = call.receiveOrNull>()?.toSet()
+ call.sessions.getOrSet {
+ UserSession("test", permissions ?: emptySet())
+ }
+ call.respond(HttpStatusCode.OK)
+ }
+ authenticate {
+ val perms = Permission.values().toList()
+ perms.forEach { permission ->
+ withPermission(permission) {
+ get("/${permission.name}") {
+ call.respond(HttpStatusCode.OK)
+ }
+ }
+ }
+ perms.fold(emptyList()) { acc, permission ->
+ val set = (acc + permission).toSet()
+ withAllPermissions(*set.toTypedArray()) {
+ get("/all/${set.joinToString("") { it.name }}") {
+ call.respond(HttpStatusCode.OK)
+ }
+ }
+ withoutPermissions(*set.toTypedArray()) {
+ get("/without/${set.joinToString("") { it.name }}") {
+ call.respond(HttpStatusCode.OK)
+ }
+ }
+ withAnyPermission(*set.toTypedArray()) {
+ get("/any/${set.joinToString("") { it.name }}") {
+ call.respond(HttpStatusCode.OK)
+ }
+ }
+
+ if (set.size > 1) {
+ withAllPermissions(permission) {
+ get("/all/${permission.name}") {
+ call.respond(HttpStatusCode.OK)
+ }
+ }
+ withoutPermissions(permission) {
+ get("/without/${permission.name}") {
+ call.respond(HttpStatusCode.OK)
+ }
+ }
+ withAnyPermission(permission) {
+ get("/any/${permission.name}") {
+ call.respond(HttpStatusCode.OK)
+ }
+ }
+ }
+ acc + permission
+ }
+ }
+ }
+ }, test = test)
+}
diff --git a/media/screenshot-android-home.png b/media/screenshot-android-home.png
new file mode 100644
index 00000000..620971a1
Binary files /dev/null and b/media/screenshot-android-home.png differ
diff --git a/media/screenshot-web-home.png b/media/screenshot-web-home.png
new file mode 100644
index 00000000..5f6f0f10
Binary files /dev/null and b/media/screenshot-web-home.png differ
diff --git a/preferences/build.gradle.kts b/preferences/build.gradle.kts
new file mode 100644
index 00000000..cf75f6fe
--- /dev/null
+++ b/preferences/build.gradle.kts
@@ -0,0 +1,46 @@
+import com.android.build.gradle.LibraryExtension
+
+plugins {
+ kotlin("multiplatform")
+ kotlin("plugin.serialization")
+}
+
+if (hasAndroidSdk) {
+ apply(plugin = "com.android.library")
+ configure {
+ compileSdk = 29
+ defaultConfig {
+ minSdk = 23
+ targetSdk = 29
+ }
+ sourceSets {
+ named("main") {
+ manifest.srcFile("src/androidMain/AndroidManifest.xml")
+ }
+ }
+ }
+}
+
+kotlin {
+ if (hasAndroidSdk) {
+ android()
+ }
+ jvm()
+ ios()
+ js(IR) {
+ browser()
+ }
+
+ sourceSets {
+ all {
+ languageSettings.apply {
+ useExperimentalAnnotation("kotlin.RequiresOptIn")
+ }
+ }
+ named("commonMain") {
+ dependencies {
+ implementation(kotlin("stdlib-common"))
+ }
+ }
+ }
+}
diff --git a/preferences/src/androidMain/AndroidManifest.xml b/preferences/src/androidMain/AndroidManifest.xml
new file mode 100644
index 00000000..8196ec74
--- /dev/null
+++ b/preferences/src/androidMain/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/preferences/src/androidMain/kotlin/AndroidPreferences.kt b/preferences/src/androidMain/kotlin/AndroidPreferences.kt
new file mode 100644
index 00000000..090f62d8
--- /dev/null
+++ b/preferences/src/androidMain/kotlin/AndroidPreferences.kt
@@ -0,0 +1,130 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.prefs
+
+import android.annotation.SuppressLint
+import android.content.SharedPreferences
+
+class AndroidPreferences(
+ private val prefs: SharedPreferences,
+) : Preferences {
+
+ override val keys: Set
+ get() = prefs.all.keys.toSet()
+
+ override val size: Int
+ get() = prefs.all.size
+
+ override fun contains(key: String): Boolean {
+ return prefs.contains(key)
+ }
+
+ override fun removeAll() {
+ prefs.edit { clear() }
+ }
+
+ override fun remove(key: String) {
+ prefs.edit { remove(key) }
+ }
+
+ override fun putInt(key: String, value: Int) {
+ prefs.edit { putInt(key, value) }
+ }
+
+ override fun getInt(key: String, defaultValue: Int): Int {
+ return prefs.getInt(key, defaultValue)
+ }
+
+ override fun getIntOrNull(key: String): Int? {
+ return if (contains(key)) prefs.getInt(key, 0) else null
+ }
+
+ override fun putLong(key: String, value: Long) {
+ prefs.edit { putLong(key, value) }
+ }
+
+ override fun getLong(key: String, defaultValue: Long): Long {
+ return prefs.getLong(key, defaultValue)
+ }
+
+ override fun getLongOrNull(key: String): Long? {
+ return if (contains(key)) prefs.getLong(key, 0L) else null
+ }
+
+ override fun putString(key: String, value: String) {
+ prefs.edit { putString(key, value) }
+ }
+
+ override fun getString(key: String, defaultValue: String): String {
+ return prefs.getString(key, defaultValue) ?: defaultValue
+ }
+
+ override fun getStringOrNull(key: String): String? {
+ return if (contains(key)) prefs.getString(key, "") else null
+ }
+
+ override fun putDouble(key: String, value: Double) {
+ putLong(key, value.toRawBits())
+ }
+
+ override fun getDouble(key: String, defaultValue: Double): Double {
+ return Double.fromBits(prefs.getLong(key, defaultValue.toRawBits()))
+ }
+
+ override fun getDoubleOrNull(key: String): Double? {
+ return if (contains(key)) getDouble(key, 0.0) else null
+ }
+
+ override fun putBoolean(key: String, value: Boolean) {
+ prefs.edit { putBoolean(key, value) }
+ }
+
+ override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
+ return prefs.getBoolean(key, defaultValue)
+ }
+
+ override fun getBooleanOrNull(key: String): Boolean? {
+ return if (contains(key)) prefs.getBoolean(key, false) else null
+ }
+
+ override fun getFloat(key: String, defaultValue: Float): Float {
+ return prefs.getFloat(key, defaultValue)
+ }
+
+ override fun putFloat(key: String, value: Float) {
+ prefs.edit { putFloat(key, value) }
+ }
+
+ override fun getFloatOrNull(key: String): Float? {
+ return if (contains(key)) prefs.getFloat(key, 0f) else null
+ }
+
+ @SuppressLint("ApplySharedPref")
+ private inline fun SharedPreferences.edit(
+ commit: Boolean = false,
+ action: SharedPreferences.Editor.() -> Unit
+ ) {
+ val editor = edit()
+ action(editor)
+ if (commit) {
+ editor.commit()
+ } else {
+ editor.apply()
+ }
+ }
+}
\ No newline at end of file
diff --git a/preferences/src/commonMain/kotlin/Preferences.kt b/preferences/src/commonMain/kotlin/Preferences.kt
new file mode 100644
index 00000000..26a8f007
--- /dev/null
+++ b/preferences/src/commonMain/kotlin/Preferences.kt
@@ -0,0 +1,53 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.prefs
+
+interface Preferences {
+
+ val keys: Set
+ val size: Int
+
+ fun contains(key: String): Boolean
+
+ fun removeAll()
+ fun remove(key: String)
+
+ fun putInt(key: String, value: Int)
+ fun getInt(key: String, defaultValue: Int = 0): Int
+ fun getIntOrNull(key: String): Int?
+
+ fun putLong(key: String, value: Long)
+ fun getLong(key: String, defaultValue: Long = 0L): Long
+ fun getLongOrNull(key: String): Long?
+
+ fun putFloat(key: String, value: Float)
+ fun getFloat(key: String, defaultValue: Float = 0f): Float
+ fun getFloatOrNull(key: String): Float?
+
+ fun putDouble(key: String, value: Double)
+ fun getDouble(key: String, defaultValue: Double = 0.0): Double
+ fun getDoubleOrNull(key: String): Double?
+
+ fun putBoolean(key: String, value: Boolean)
+ fun getBoolean(key: String, defaultValue: Boolean = false): Boolean
+ fun getBooleanOrNull(key: String): Boolean?
+
+ fun putString(key: String, value: String)
+ fun getString(key: String, defaultValue: String = ""): String
+ fun getStringOrNull(key: String): String?
+}
\ No newline at end of file
diff --git a/preferences/src/iosMain/kotlin/IosPreferences.kt b/preferences/src/iosMain/kotlin/IosPreferences.kt
new file mode 100644
index 00000000..187608e2
--- /dev/null
+++ b/preferences/src/iosMain/kotlin/IosPreferences.kt
@@ -0,0 +1,121 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.prefs
+
+import platform.Foundation.NSUserDefaults
+import kotlin.native.concurrent.freeze
+
+class IosPreferences(
+ private val prefs: NSUserDefaults
+) : Preferences {
+
+ init {
+ freeze()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ override val keys: Set
+ get() = prefs.dictionaryRepresentation().keys as Set
+
+ override val size: Int
+ get() = prefs.dictionaryRepresentation().size
+
+ override fun contains(key: String): Boolean {
+ return prefs.objectForKey(key) != null
+ }
+
+ override fun removeAll() {
+ keys.forEach(prefs::removeObjectForKey)
+ }
+
+ override fun remove(key: String) {
+ prefs.removeObjectForKey(key)
+ }
+
+ override fun putInt(key: String, value: Int) {
+ prefs.setInteger(value.toLong(), key)
+ }
+
+ override fun getInt(key: String, defaultValue: Int): Int {
+ return if (contains(key)) prefs.integerForKey(key).toInt() else defaultValue
+ }
+
+ override fun getIntOrNull(key: String): Int? {
+ return if (contains(key)) prefs.integerForKey(key).toInt() else null
+ }
+
+ override fun putLong(key: String, value: Long) {
+ prefs.setInteger(value, key)
+ }
+
+ override fun getLong(key: String, defaultValue: Long): Long {
+ return if (contains(key)) prefs.integerForKey(key) else defaultValue
+ }
+
+ override fun getLongOrNull(key: String): Long? {
+ return if (contains(key)) prefs.integerForKey(key) else null
+ }
+
+ override fun putString(key: String, value: String) {
+ prefs.setObject(value, key)
+ }
+
+ override fun getString(key: String, defaultValue: String): String {
+ return if (contains(key)) prefs.stringForKey(key) ?: defaultValue else defaultValue
+ }
+
+ override fun getStringOrNull(key: String): String? {
+ return if (contains(key)) prefs.stringForKey(key) else null
+ }
+
+ override fun putDouble(key: String, value: Double) {
+ prefs.setDouble(value, key)
+ }
+
+ override fun getDouble(key: String, defaultValue: Double): Double {
+ return if (contains(key)) prefs.doubleForKey(key) else defaultValue
+ }
+
+ override fun getDoubleOrNull(key: String): Double? {
+ return if (contains(key)) prefs.doubleForKey(key) else null
+ }
+
+ override fun putBoolean(key: String, value: Boolean) {
+ prefs.setBool(value, key)
+ }
+
+ override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
+ return if (contains(key)) prefs.boolForKey(key) else defaultValue
+ }
+
+ override fun getBooleanOrNull(key: String): Boolean? {
+ return if (contains(key)) prefs.boolForKey(key) else null
+ }
+
+ override fun putFloat(key: String, value: Float) {
+ prefs.setFloat(value, key)
+ }
+
+ override fun getFloat(key: String, defaultValue: Float): Float {
+ return if (contains(key)) prefs.floatForKey(key) else defaultValue
+ }
+
+ override fun getFloatOrNull(key: String): Float? {
+ return if (contains(key)) prefs.floatForKey(key) else null
+ }
+}
\ No newline at end of file
diff --git a/preferences/src/jsMain/kotlin/JsPreferences.kt b/preferences/src/jsMain/kotlin/JsPreferences.kt
new file mode 100644
index 00000000..4a3c06aa
--- /dev/null
+++ b/preferences/src/jsMain/kotlin/JsPreferences.kt
@@ -0,0 +1,115 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.prefs
+
+import org.w3c.dom.Storage
+
+class JsPreferences(
+ private val storage: Storage
+) : Preferences {
+
+ override val keys: Set
+ get() = List(storage.length, storage::key).filterNotNull().toSet()
+
+ override val size: Int
+ get() = storage.length
+
+ override fun contains(key: String): Boolean {
+ return storage.getItem(key) != null
+ }
+
+ override fun removeAll() {
+ storage.clear()
+ }
+
+ override fun remove(key: String) {
+ storage.removeItem(key)
+ }
+
+ override fun putInt(key: String, value: Int) {
+ storage.setItem(key, value.toString())
+ }
+
+ override fun getInt(key: String, defaultValue: Int): Int {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toInt() else defaultValue
+ }
+
+ override fun getIntOrNull(key: String): Int? {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toInt() else null
+ }
+
+ override fun putLong(key: String, value: Long) {
+ storage.setItem(key, value.toString())
+ }
+
+ override fun getLong(key: String, defaultValue: Long): Long {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toLong() else defaultValue
+ }
+
+ override fun getLongOrNull(key: String): Long? {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toLong() else null
+ }
+
+ override fun putString(key: String, value: String) {
+ storage.setItem(key, value)
+ }
+
+ override fun getString(key: String, defaultValue: String): String {
+ return if (contains(key)) checkNotNull(storage.getItem(key)) else defaultValue
+ }
+
+ override fun getStringOrNull(key: String): String? {
+ return if (contains(key)) checkNotNull(storage.getItem(key)) else null
+ }
+
+ override fun putDouble(key: String, value: Double) {
+ storage.setItem(key, value.toString())
+ }
+
+ override fun getDouble(key: String, defaultValue: Double): Double {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toDouble() else defaultValue
+ }
+
+ override fun getDoubleOrNull(key: String): Double? {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toDouble() else null
+ }
+
+ override fun putBoolean(key: String, value: Boolean) {
+ storage.setItem(key, value.toString())
+ }
+
+ override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toBoolean() else defaultValue
+ }
+
+ override fun getBooleanOrNull(key: String): Boolean? {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toBoolean() else null
+ }
+
+ override fun putFloat(key: String, value: Float) {
+ storage.setItem(key, value.toString())
+ }
+
+ override fun getFloat(key: String, defaultValue: Float): Float {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toFloat() else defaultValue
+ }
+
+ override fun getFloatOrNull(key: String): Float? {
+ return if (contains(key)) checkNotNull(storage.getItem(key)).toFloat() else null
+ }
+}
\ No newline at end of file
diff --git a/server/build.gradle.kts b/server/build.gradle.kts
new file mode 100644
index 00000000..1682d424
--- /dev/null
+++ b/server/build.gradle.kts
@@ -0,0 +1,85 @@
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+plugins {
+ application
+ kotlin("jvm")
+ kotlin("plugin.serialization")
+ id("com.github.johnrengelman.shadow") version "7.0.0"
+}
+
+application {
+ mainClass.set("io.ktor.server.netty.EngineMain")
+}
+
+kotlin {
+ sourceSets {
+ main {
+ dependencies {
+ implementation(project(":data-models"))
+
+ implementation(libs.serialization.json)
+ implementation(libs.coroutines.core)
+ implementation(libs.coroutines.jdk8)
+
+ implementation(libs.ktor.server.core)
+ implementation(libs.ktor.server.netty)
+ implementation(libs.ktor.server.sessions)
+ implementation(libs.ktor.server.metrics)
+ implementation(libs.ktor.server.auth)
+ implementation(libs.ktor.server.authJwt)
+ implementation(libs.ktor.server.serialization)
+ implementation(libs.ktor.server.websockets)
+
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.ktor.client.json)
+ implementation(libs.ktor.client.serialization)
+
+ implementation(libs.bouncyCastle)
+
+ implementation(libs.logback)
+
+ implementation(libs.kmongo.coroutine.serialization)
+
+ implementation(libs.jaffree)
+
+ implementation(libs.tmdbapi)
+
+ implementation(libs.qbittorrent.client)
+ implementation(projects.torrentSearch)
+ implementation(projects.ktorPermissions)
+ }
+ }
+
+ test {
+ dependencies {
+ implementation(libs.ktor.server.tests)
+ }
+ }
+ }
+}
+
+tasks.withType {
+ kotlinOptions {
+ freeCompilerArgs += listOf(
+ "-XXLanguage:+InlineClasses",
+ "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
+ "-Xopt-in=kotlinx.coroutines.FlowPreview",
+ "-Xopt-in=kotlin.time.ExperimentalTime",
+ "-Xopt-in=kotlin.RequiresOptIn"
+ )
+ }
+}
+
+tasks.withType {
+ manifest {
+ archiveFileName.set("server.jar")
+ attributes(
+ mapOf(
+ "Main-Class" to application.mainClass.get()
+ )
+ )
+ }
+}
diff --git a/server/src/main/kotlin/Application.kt b/server/src/main/kotlin/Application.kt
new file mode 100644
index 00000000..9989d498
--- /dev/null
+++ b/server/src/main/kotlin/Application.kt
@@ -0,0 +1,129 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream
+
+import anystream.data.UserSession
+import anystream.routes.installRouting
+import anystream.util.MongoSessionStorage
+import anystream.util.PermissionAuthorization
+import com.mongodb.ConnectionString
+import io.ktor.application.*
+import io.ktor.auth.*
+import io.ktor.features.*
+import io.ktor.http.*
+import io.ktor.http.HttpStatusCode.Companion.Unauthorized
+import io.ktor.http.cio.websocket.*
+import io.ktor.http.content.CachingOptions
+import io.ktor.response.*
+import io.ktor.serialization.*
+import io.ktor.sessions.*
+import io.ktor.util.date.GMTDate
+import io.ktor.websocket.*
+import kotlinx.serialization.json.Json
+import org.bouncycastle.util.encoders.Hex
+import org.litote.kmongo.coroutine.coroutine
+import org.litote.kmongo.reactivestreams.KMongo
+import org.slf4j.event.Level
+import java.time.Duration
+import kotlin.random.Random
+
+fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args)
+
+val json = Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ classDiscriminator = "__type"
+ allowStructuredMapKeys = true
+}
+
+@Suppress("unused") // Referenced in application.conf
+@kotlin.jvm.JvmOverloads
+fun Application.module(testing: Boolean = false) {
+
+ val mongoUrl = environment.config.property("app.mongoUrl").getString()
+ val databaseName = environment.config.propertyOrNull("app.databaseName")
+ ?.getString() ?: "anystream"
+
+ val kmongo = KMongo.createClient(ConnectionString(mongoUrl))
+ val mongodb = kmongo.getDatabase(databaseName).coroutine
+
+ install(DefaultHeaders) {}
+ install(ContentNegotiation) { json(json) }
+ install(AutoHeadResponse)
+ install(ConditionalHeaders)
+ install(PartialContent)
+ //install(ForwardedHeaderSupport) WARNING: for security, do not include this if not behind a reverse proxy
+ //install(XForwardedHeaderSupport) WARNING: for security, do not include this if not behind a reverse proxy
+ install(Compression) {
+ gzip {
+ priority = 1.0
+ }
+ deflate {
+ priority = 10.0
+ minimumSize(1024) // condition
+ }
+ excludeContentType(ContentType.Video.Any)
+ }
+
+ install(CORS) {
+ methods.addAll(HttpMethod.DefaultMethods)
+ allowCredentials = true
+ allowNonSimpleContentTypes = true
+ allowHeaders { true }
+ exposeHeader(UserSession.KEY)
+ anyHost()
+ }
+
+ install(CallLogging) {
+ level = Level.TRACE
+ }
+
+ install(CachingHeaders) {
+ options { outgoingContent ->
+ when (outgoingContent.contentType?.withoutParameters()) {
+ ContentType.Text.CSS -> CachingOptions(
+ CacheControl.MaxAge(maxAgeSeconds = 24 * 60 * 60),
+ expires = null as? GMTDate?
+ )
+ else -> null
+ }
+ }
+ }
+
+ install(WebSockets) {
+ pingPeriod = Duration.ofSeconds(60)
+ timeout = Duration.ofSeconds(15)
+ }
+
+ install(Authentication) {
+ session {
+ challenge { context.respond(Unauthorized) }
+ validate { it }
+ }
+ }
+ install(Sessions) {
+ header(UserSession.KEY, MongoSessionStorage(mongodb, log)) {
+ identity { Hex.toHexString(Random.nextBytes(48)) }
+ }
+ }
+ install(PermissionAuthorization) {
+ extract { (it as UserSession).permissions }
+ }
+ installRouting(mongodb)
+}
diff --git a/server/src/main/kotlin/data/ModelExtensions.kt b/server/src/main/kotlin/data/ModelExtensions.kt
new file mode 100644
index 00000000..04adce53
--- /dev/null
+++ b/server/src/main/kotlin/data/ModelExtensions.kt
@@ -0,0 +1,115 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.data
+
+import anystream.models.Image
+import anystream.models.Movie
+import anystream.models.api.TmdbMoviesResponse
+import anystream.models.api.TmdbTvShowResponse
+import anystream.models.tmdb.CompleteTvSeries
+import anystream.models.tmdb.PartialMovie
+import anystream.models.tmdb.PartialTvSeries
+import info.movito.themoviedbapi.TvResultsPage
+import info.movito.themoviedbapi.model.ArtworkType
+import info.movito.themoviedbapi.model.MovieDb
+import info.movito.themoviedbapi.model.core.MovieResultsPage
+import info.movito.themoviedbapi.model.keywords.Keyword
+import info.movito.themoviedbapi.model.tv.TvSeries
+import java.time.Instant
+
+private const val MAX_CACHED_POSTERS = 5
+
+fun MovieResultsPage.asApiResponse(existingRecordIds: List): TmdbMoviesResponse {
+ val ids = existingRecordIds.toMutableList()
+ return when (results) {
+ null -> TmdbMoviesResponse()
+ else -> TmdbMoviesResponse(
+ items = results.map { it.asPartialMovie(ids) },
+ itemTotal = totalResults,
+ page = page,
+ pageTotal = totalPages
+ )
+ }
+}
+
+fun MovieDb.asPartialMovie(
+ existingRecordIds: MutableList? = null
+) = PartialMovie(
+ tmdbId = id,
+ title = title,
+ overview = overview,
+ posterPath = posterPath,
+ releaseDate = releaseDate,
+ backdropPath = backdropPath,
+ isAdded = existingRecordIds?.remove(id) ?: false
+)
+
+fun MovieDb.asMovie(
+ id: String,
+ userId: String
+) = Movie(
+ id = id,
+ tmdbId = this.id,
+ title = title,
+ overview = overview,
+ posterPath = posterPath,
+ releaseDate = releaseDate,
+ backdropPath = backdropPath,
+ imdbId = imdbID,
+ runtime = runtime,
+ posters = getImages(ArtworkType.POSTER)
+ .filter { "en".equals(it.language, true) }
+ .take(MAX_CACHED_POSTERS)
+ .map { img ->
+ Image(
+ filePath = img.filePath,
+ language = img.language ?: ""
+ )
+ },
+ added = Instant.now().toEpochMilli(),
+ addedByUserId = userId
+)
+
+fun TvResultsPage.asApiResponse() =
+ when (results) {
+ null -> TmdbTvShowResponse()
+ else -> TmdbTvShowResponse(
+ items = results.map { it.asPartialTvSeries() },
+ itemTotal = totalResults,
+ page = page,
+ pageTotal = totalPages
+ )
+ }
+
+fun TvSeries.asPartialTvSeries() = PartialTvSeries(
+ tmdbId = id,
+ name = name,
+ overview = overview,
+ firstAirDate = firstAirDate,
+ lastAirDate = lastAirDate
+)
+
+
+fun TvSeries.asCompleteTvSeries() = CompleteTvSeries(
+ tmdbId = id,
+ name = name,
+ overview = overview,
+ firstAirDate = firstAirDate,
+ lastAirDate = lastAirDate,
+ keywords = keywords.map(Keyword::getName)
+)
diff --git a/server/src/main/kotlin/data/UserSession.kt b/server/src/main/kotlin/data/UserSession.kt
new file mode 100644
index 00000000..525bbc9f
--- /dev/null
+++ b/server/src/main/kotlin/data/UserSession.kt
@@ -0,0 +1,34 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.data
+
+import anystream.util.Permission
+import io.ktor.auth.*
+import kotlinx.serialization.Serializable
+import java.time.Instant
+
+@Serializable
+data class UserSession(
+ val userId: String,
+ val permissions: Set,
+ val sessionStarted: Long = Instant.now().toEpochMilli()
+) : Principal {
+ companion object {
+ const val KEY = "as_user_session"
+ }
+}
diff --git a/server/src/main/kotlin/media/MediaImportProcessor.kt b/server/src/main/kotlin/media/MediaImportProcessor.kt
new file mode 100644
index 00000000..aa20986d
--- /dev/null
+++ b/server/src/main/kotlin/media/MediaImportProcessor.kt
@@ -0,0 +1,31 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.media
+
+import anystream.models.MediaKind
+import anystream.models.api.ImportMediaResult
+import org.slf4j.Marker
+import java.io.File
+
+
+interface MediaImportProcessor {
+
+ val mediaKinds: List
+
+ suspend fun process(contentFile: File, userId: String, marker: Marker): ImportMediaResult
+}
\ No newline at end of file
diff --git a/server/src/main/kotlin/media/MediaImporter.kt b/server/src/main/kotlin/media/MediaImporter.kt
new file mode 100644
index 00000000..ef7a43c6
--- /dev/null
+++ b/server/src/main/kotlin/media/MediaImporter.kt
@@ -0,0 +1,136 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.media
+
+import anystream.models.LocalMediaReference
+import anystream.models.MediaReference
+import anystream.models.api.ImportMedia
+import anystream.models.api.ImportMediaResult
+import anystream.routes.concurrentMap
+import info.movito.themoviedbapi.TmdbApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.*
+import org.litote.kmongo.coroutine.CoroutineCollection
+import org.litote.kmongo.exists
+import org.slf4j.Logger
+import org.slf4j.Marker
+import org.slf4j.MarkerFactory
+import java.io.File
+import java.util.UUID
+
+class MediaImporter(
+ tmdb: TmdbApi,
+ private val processors: List,
+ private val mediaRefs: CoroutineCollection,
+ private val logger: Logger,
+) {
+ private val classMarker = MarkerFactory.getMarker(this::class.simpleName)
+
+ // Within a specified content directory, find all content unknown to anystream
+ suspend fun findUnmappedFiles(userId: String, request: ImportMedia): List {
+ val contentFile = File(request.contentPath)
+ if (!contentFile.exists()) {
+ return emptyList()
+ }
+
+ val mediaRefPaths = mediaRefs
+ .withDocumentClass()
+ .find(LocalMediaReference::filePath.exists())
+ .toList()
+ .map(LocalMediaReference::filePath)
+
+ return contentFile.listFiles()
+ ?.toList()
+ .orEmpty()
+ .filter { file ->
+ mediaRefPaths.none { ref ->
+ ref.startsWith(file.absolutePath)
+ }
+ }
+ .map(File::getAbsolutePath)
+ }
+
+ suspend fun importAll(userId: String, request: ImportMedia): Flow {
+ val marker = marker()
+ logger.debug(marker, "Recursive import requested by '$userId': $request")
+ val contentFile = File(request.contentPath)
+ if (!contentFile.exists()) {
+ logger.debug(marker, "Root content directory not found: ${contentFile.absolutePath}")
+ return flowOf(ImportMediaResult.ErrorFileNotFound)
+ }
+
+ return contentFile.listFiles()
+ ?.toList()
+ .orEmpty()
+ .asFlow()
+ .concurrentMap(GlobalScope, 10) { file ->
+ internalImport(
+ userId,
+ request.copy(contentPath = file.absolutePath),
+ marker,
+ )
+ }
+ .onCompletion { error ->
+ if (error == null) {
+ logger.debug(marker, "Recursive import completed")
+ } else {
+ logger.debug(marker, "Recursive import interrupted", error)
+ }
+ }
+ }
+
+ suspend fun import(userId: String, request: ImportMedia): ImportMediaResult {
+ val marker = marker()
+ logger.debug(marker, "Import requested by '$userId': $request")
+
+ val contentFile = File(request.contentPath)
+ if (!contentFile.exists()) {
+ logger.debug(marker, "Content file not found: ${contentFile.absolutePath}")
+ return ImportMediaResult.ErrorFileNotFound
+ }
+
+ return internalImport(userId, request, marker)
+ }
+
+ // Process a single media file and attempt to import missing data and references
+ private suspend fun internalImport(
+ userId: String,
+ request: ImportMedia,
+ marker: Marker,
+ ): ImportMediaResult {
+ val contentFile = File(request.contentPath)
+ logger.debug(marker, "Importing content file: ${contentFile.absolutePath}")
+ if (!contentFile.exists()) {
+ logger.debug(marker, "Content file not found")
+ return ImportMediaResult.ErrorFileNotFound
+ }
+
+ return processors
+ .mapNotNull { processor ->
+ if (processor.mediaKinds.contains(request.mediaKind)) {
+ processor.process(contentFile, userId, marker)
+ } else null
+ }
+ .firstOrNull()
+ ?: ImportMediaResult.ErrorNothingToImport
+ }
+
+ // Create a unique nested marker to identify import requests
+ private fun marker() = MarkerFactory.getMarker(UUID.randomUUID().toString())
+ .apply { add(classMarker) }
+}
diff --git a/server/src/main/kotlin/media/processor/MovieImportProcessor.kt b/server/src/main/kotlin/media/processor/MovieImportProcessor.kt
new file mode 100644
index 00000000..b633e261
--- /dev/null
+++ b/server/src/main/kotlin/media/processor/MovieImportProcessor.kt
@@ -0,0 +1,176 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.media.processor
+
+import anystream.data.asMovie
+import anystream.media.MediaImportProcessor
+import anystream.models.LocalMediaReference
+import anystream.models.MediaReference
+import anystream.models.MediaKind
+import anystream.models.Movie
+import anystream.models.api.ImportMediaResult
+import com.mongodb.MongoException
+import info.movito.themoviedbapi.TmdbApi
+import info.movito.themoviedbapi.TmdbMovies
+import org.bson.types.ObjectId
+import org.litote.kmongo.coroutine.CoroutineDatabase
+import org.litote.kmongo.eq
+import org.slf4j.Logger
+import org.slf4j.Marker
+import java.io.File
+import java.time.Instant
+
+class MovieImportProcessor(
+ private val tmdb: TmdbApi,
+ mongodb: CoroutineDatabase,
+ private val logger: Logger,
+) : MediaImportProcessor {
+
+ private val moviesDb = mongodb.getCollection()
+ private val mediaRefs = mongodb.getCollection()
+ private val yearRegex = "\\((\\d\\d\\d\\d)\\)".toRegex()
+
+ override val mediaKinds: List = listOf(MediaKind.MOVIE)
+
+ override suspend fun process(contentFile: File, userId: String, marker: Marker): ImportMediaResult {
+ val movieFile = if (contentFile.isFile) {
+ logger.debug(marker, "Detected single content file")
+ contentFile
+ } else {
+ logger.debug(marker, "Detected content directory")
+ contentFile.listFiles()
+ ?.toList()
+ .orEmpty()
+ .maxByOrNull(File::length)
+ ?.also { result ->
+ logger.debug(marker, "Largest content file found: ${result.absolutePath}")
+ }
+ }
+
+ if (movieFile == null) {
+ logger.debug(marker, "Content file not found")
+ return ImportMediaResult.ErrorMediaRefNotFound
+ }
+
+ val existingRef = try {
+ mediaRefs.findOne(LocalMediaReference::filePath eq movieFile.absolutePath)
+ } catch (e: MongoException) {
+ return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())
+ }
+ if (existingRef != null) {
+ logger.debug(marker, "Content file reference already exists")
+ return ImportMediaResult.ErrorMediaRefAlreadyExists(existingRef.id)
+ }
+
+ val match = yearRegex.find(movieFile.nameWithoutExtension)
+ val year = match?.value?.trim('(', ')')?.toInt() ?: 0
+
+ logger.debug(marker, "Found content year: $year")
+
+ // TODO: Improve query capabilities
+ val query = movieFile.nameWithoutExtension
+ .replace(yearRegex, "")
+ .trim()
+ val response = try {
+ logger.debug(marker, "Querying provider for '$query'")
+ tmdb.search.searchMovie(query, year, null, false, 0)
+ } catch (e: Throwable) {
+ logger.debug(marker, "Provider lookup error", e)
+ return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString())
+ }
+ logger.debug(marker, "Provider returned ${response.totalResults} results")
+ if (response.results.isEmpty()) {
+ return ImportMediaResult.ErrorMediaMatchNotFound(contentFile.absolutePath, query)
+ }
+
+ val tmdbMovie = response.results.first().apply {
+ logger.debug(marker, "Detected media as ${id}:'${title}' (${releaseDate})")
+ }
+
+ val existingRecord = moviesDb.findOne(Movie::tmdbId eq tmdbMovie.id)
+ return if (existingRecord == null) {
+ logger.debug(marker, "Movie data import required")
+ movieFile.importMovie(userId, tmdbMovie.id, marker)
+ } else {
+ logger.debug(marker, "Movie data exists at ${existingRecord.id}, creating media ref")
+ val mediaRefId = ObjectId.get().toString()
+ try {
+ mediaRefs.insertOne(
+ LocalMediaReference(
+ id = mediaRefId,
+ contentId = existingRecord.id,
+ added = Instant.now().toEpochMilli(),
+ addedByUserId = userId,
+ filePath = movieFile.absolutePath,
+ mediaKind = MediaKind.MOVIE,
+ directory = false,
+ )
+ )
+ ImportMediaResult.Success(existingRecord.id, mediaRefId)
+ } catch (e: MongoException) {
+ logger.debug(marker, "Failed to create media reference", e)
+ ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())
+ }
+ }
+ }
+
+ // Import movie data and create media ref
+ private suspend fun File.importMovie(
+ userId: String,
+ tmdbId: Int,
+ marker: Marker
+ ): ImportMediaResult {
+ val movie = try {
+ tmdb.movies.getMovie(
+ tmdbId,
+ null,
+ TmdbMovies.MovieMethod.images,
+ TmdbMovies.MovieMethod.release_dates,
+ TmdbMovies.MovieMethod.alternative_titles,
+ TmdbMovies.MovieMethod.keywords
+ )
+ } catch (e: Throwable) {
+ logger.debug(marker, "Extended provider data query failed", e)
+ return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString())
+ }
+ val movieId = ObjectId.get().toString()
+ val mediaRefId = ObjectId.get().toString()
+ try {
+ moviesDb.insertOne(movie.asMovie(movieId, userId))
+ mediaRefs.insertOne(
+ LocalMediaReference(
+ id = mediaRefId,
+ contentId = movieId,
+ added = Instant.now().toEpochMilli(),
+ addedByUserId = userId,
+ filePath = absolutePath,
+ mediaKind = MediaKind.MOVIE,
+ directory = false,
+ )
+ )
+ logger.debug(
+ marker,
+ "Movie and media ref created movieId=$movieId, mediaRefId=$mediaRefId"
+ )
+ return ImportMediaResult.Success(movieId, mediaRefId)
+ } catch (e: MongoException) {
+ logger.debug(marker, "Movie or media ref creation failed", e)
+ return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())
+ }
+ }
+}
\ No newline at end of file
diff --git a/server/src/main/kotlin/media/processor/TvImportProcessor.kt b/server/src/main/kotlin/media/processor/TvImportProcessor.kt
new file mode 100644
index 00000000..e083cceb
--- /dev/null
+++ b/server/src/main/kotlin/media/processor/TvImportProcessor.kt
@@ -0,0 +1,291 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.media.processor
+
+import anystream.media.MediaImportProcessor
+import anystream.models.*
+import anystream.models.api.ImportMediaResult
+import anystream.routes.concurrentMap
+import com.mongodb.MongoException
+import com.mongodb.MongoQueryException
+import info.movito.themoviedbapi.TmdbApi
+import info.movito.themoviedbapi.TmdbTV
+import info.movito.themoviedbapi.TmdbTvSeasons
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.toList
+import org.bson.types.ObjectId
+import org.litote.kmongo.coroutine.CoroutineDatabase
+import org.litote.kmongo.eq
+import org.slf4j.Logger
+import org.slf4j.Marker
+import java.io.File
+import java.time.Instant
+
+class TvImportProcessor(
+ private val tmdb: TmdbApi,
+ mongodb: CoroutineDatabase,
+ private val logger: Logger,
+) : MediaImportProcessor {
+
+ private val tvShowDb = mongodb.getCollection()
+ private val episodeDb = mongodb.getCollection()
+ private val mediaRefDb = mongodb.getCollection()
+
+ override val mediaKinds: List = listOf(MediaKind.TV)
+
+ private val episodeRegex = "(.*) - S([0-9]{1,2})E([0-9]{1,2}) - (.*)".toRegex()
+
+ override suspend fun process(
+ contentFile: File,
+ userId: String,
+ marker: Marker,
+ ): ImportMediaResult {
+ if (contentFile.isFile) {
+ logger.debug(marker, "Detected single content file, nothing to import")
+ // TODO: Identify single files as episodes or supplemental content
+ return ImportMediaResult.ErrorNothingToImport
+ } else if (contentFile.listFiles().isNullOrEmpty()) {
+ logger.debug(marker, "Content folder is empty.")
+ return ImportMediaResult.ErrorNothingToImport
+ }
+
+ val existingRef = try {
+ mediaRefDb.findOne(LocalMediaReference::filePath eq contentFile.absolutePath)
+ } catch (e: MongoQueryException) {
+ return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())
+ }
+ if (existingRef != null) {
+ logger.debug(marker, "Content file reference already exists")
+ return ImportMediaResult.ErrorMediaRefAlreadyExists(existingRef.id)
+ }
+
+ // TODO: Improve query capabilities
+ val query = contentFile.name
+ val response = try {
+ logger.debug(marker, "Querying provider for '$query'")
+ tmdb.search.searchTv(query, "en", 1)
+ } catch (e: Throwable) {
+ logger.debug(marker, "Provider lookup error", e)
+ return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString())
+ }
+ logger.debug(marker, "Provider returned ${response.totalResults} results")
+ if (response.results.isEmpty()) {
+ return ImportMediaResult.ErrorMediaMatchNotFound(contentFile.path, query)
+ }
+
+ val tmdbShow = response.results.first().apply {
+ logger.debug(marker, "Detected media as ${id}:'${name}' (${firstAirDate})")
+ }
+
+ val existingRecord = tvShowDb.findOne(TvShow::tmdbId eq tmdbShow.id)
+ val (show, episodes) = existingRecord?.let { show ->
+ show to episodeDb.find(Episode::showId eq show.id).toList()
+ } ?: try {
+ logger.debug(marker, "Show data import required")
+ importShow(tmdbShow.id)
+ } catch (e: MongoException) {
+ logger.debug(marker, "Failed to insert new show", e)
+ return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())
+ } catch (e: Throwable) {
+ logger.debug(marker, "Data provider query failed", e)
+ return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString())
+ }
+
+ val mediaRefId = ObjectId.get().toString()
+ try {
+ mediaRefDb.insertOne(
+ LocalMediaReference(
+ id = mediaRefId,
+ contentId = show.id,
+ added = Instant.now().toEpochMilli(),
+ addedByUserId = userId,
+ filePath = contentFile.absolutePath,
+ mediaKind = MediaKind.TV,
+ directory = true,
+ )
+ )
+ } catch (e: MongoException) {
+ logger.debug(marker, "Failed to create media reference", e)
+ return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())
+ }
+
+ val subFolders = contentFile.listFiles()?.toList().orEmpty()
+ val seasonDirectories = subFolders
+ .filter { it.isDirectory && it.name.startsWith("season", true) }
+ .mapNotNull { file ->
+ file.name
+ .split(" ")
+ .lastOrNull()
+ ?.toIntOrNull()
+ ?.let { num -> show.seasons.firstOrNull { it.seasonNumber == num } }
+ ?.let { it to file }
+ }
+
+ val seasonResults = seasonDirectories.asFlow()
+ .concurrentMap(GlobalScope, 5) { (season, folder) ->
+ folder.importSeason(userId, season, episodes, marker)
+ }
+ .toList()
+
+ return ImportMediaResult.Success(
+ mediaId = show.id,
+ mediaRefId = mediaRefId,
+ subresults = seasonResults,
+ )
+ }
+
+ // Import show data
+ private suspend fun importShow(tmdbId: Int): Pair> {
+ val showId = ObjectId.get().toString()
+ val tmdbShow = tmdb.tvSeries.getSeries(
+ tmdbId,
+ "en",
+ TmdbTV.TvMethod.keywords,
+ TmdbTV.TvMethod.external_ids,
+ TmdbTV.TvMethod.images,
+ TmdbTV.TvMethod.content_ratings,
+ TmdbTV.TvMethod.credits,
+ )
+ val tmdbSeasons = tmdbShow.seasons
+ .map { season ->
+ tmdb.tvSeasons.getSeason(
+ tmdbId,
+ season.seasonNumber,
+ "en",
+ TmdbTvSeasons.SeasonMethod.images,
+ )
+ }
+ val episodes = tmdbSeasons.flatMap { season ->
+ season.episodes.map { episode ->
+ Episode(
+ id = ObjectId.get().toString(),
+ tmdbId = episode.id,
+ name = episode.name,
+ overview = episode.overview,
+ airDate = episode.airDate ?: "",
+ number = episode.episodeNumber,
+ seasonNumber = episode.seasonNumber,
+ showId = showId,
+ stillPath = episode.stillPath ?: ""
+ )
+ }
+ }
+ val show = TvShow(
+ id = showId,
+ name = tmdbShow.name,
+ tmdbId = tmdbShow.id,
+ overview = tmdbShow.overview,
+ firstAirDate = tmdbShow.firstAirDate ?: "",
+ numberOfSeasons = tmdbShow.numberOfSeasons,
+ numberOfEpisodes = tmdbShow.numberOfEpisodes,
+ posterPath = tmdbShow.posterPath ?: "",
+ added = Instant.now().toEpochMilli(),
+ seasons = tmdbSeasons.map { season ->
+ TvSeason(
+ id = ObjectId.get().toString(),
+ tmdbId = season.id,
+ name = season.name,
+ overview = season.overview,
+ seasonNumber = season.seasonNumber,
+ airDate = season.airDate ?: "",
+ posterPath = season.posterPath ?: "",
+ )
+ }
+ )
+
+ tvShowDb.insertOne(show)
+ episodeDb.insertMany(episodes)
+ return show to episodes
+ }
+
+ private suspend fun File.importSeason(
+ userId: String,
+ season: TvSeason,
+ episodes: List,
+ marker: Marker,
+ ): ImportMediaResult {
+ val seasonRefId = ObjectId.get().toString()
+ try {
+ mediaRefDb.insertOne(
+ LocalMediaReference(
+ id = seasonRefId,
+ contentId = season.id,
+ added = Instant.now().toEpochMilli(),
+ addedByUserId = userId,
+ filePath = absolutePath,
+ mediaKind = MediaKind.TV,
+ directory = true,
+ )
+ )
+ } catch (e: MongoException) {
+ logger.debug(marker, "Failed to create season media ref", e)
+ return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())
+ }
+
+ val episodeFiles = listFiles()?.toList().orEmpty()
+ .sortedByDescending(File::length)
+ .filter { it.isFile && it.nameWithoutExtension.matches(episodeRegex) }
+
+ val episodeFileMatches = episodeFiles.map { episodeFile ->
+ val nameParts = episodeRegex.find(episodeFile.nameWithoutExtension)!!
+ val (_, seasonNumber, episodeNumber, _) = nameParts.destructured
+
+ episodeFile to episodes.find { episode ->
+ episode.seasonNumber == seasonNumber.toIntOrNull() &&
+ episode.number == episodeNumber.toIntOrNull()
+ }
+ }
+
+ val episodeRefs = episodeFileMatches
+ .mapNotNull { (file, episode) ->
+ episode?.let {
+ LocalMediaReference(
+ id = ObjectId.get().toString(),
+ contentId = episode.id,
+ added = Instant.now().toEpochMilli(),
+ addedByUserId = userId,
+ filePath = file.absolutePath,
+ mediaKind = MediaKind.TV,
+ directory = false,
+ rootContentId = episode.showId,
+ )
+ }
+ }
+ val results = try {
+ if (episodeRefs.isNotEmpty()) {
+ mediaRefDb.insertMany(episodeRefs)
+ episodeRefs.map { ref ->
+ ImportMediaResult.Success(
+ mediaId = ref.contentId,
+ mediaRefId = ref.id,
+ )
+ }
+ } else emptyList()
+ } catch (e: MongoException) {
+ logger.debug(marker, "Error creating episode references", e)
+ listOf(ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()))
+ }
+
+ return ImportMediaResult.Success(
+ mediaId = season.id,
+ mediaRefId = seasonRefId,
+ subresults = results,
+ )
+ }
+}
\ No newline at end of file
diff --git a/server/src/main/kotlin/modules/StatusPageModule.kt b/server/src/main/kotlin/modules/StatusPageModule.kt
new file mode 100644
index 00000000..347fc789
--- /dev/null
+++ b/server/src/main/kotlin/modules/StatusPageModule.kt
@@ -0,0 +1,41 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.modules
+
+import io.ktor.application.Application
+import io.ktor.application.call
+import io.ktor.application.install
+import io.ktor.features.StatusPages
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.response.respondText
+
+
+@Suppress("unused") // Referenced in application.conf
+fun Application.module() {
+ install(StatusPages) {
+ // TODO: Enable only in development mode
+ exception { error ->
+ call.respondText(
+ status = HttpStatusCode.InternalServerError,
+ contentType = ContentType.Text.Plain,
+ text = error.stackTraceToString()
+ )
+ }
+ }
+}
diff --git a/server/src/main/kotlin/routes/Home.kt b/server/src/main/kotlin/routes/Home.kt
new file mode 100644
index 00000000..f7b6b9e4
--- /dev/null
+++ b/server/src/main/kotlin/routes/Home.kt
@@ -0,0 +1,111 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.data.UserSession
+import anystream.data.asApiResponse
+import anystream.models.*
+import anystream.models.api.HomeResponse
+import info.movito.themoviedbapi.TmdbApi
+import info.movito.themoviedbapi.model.MovieDb
+import io.ktor.application.*
+import io.ktor.auth.*
+import io.ktor.response.*
+import io.ktor.routing.*
+import org.litote.kmongo.*
+import org.litote.kmongo.coroutine.CoroutineDatabase
+
+
+fun Route.addHomeRoutes(tmdb: TmdbApi, mongodb: CoroutineDatabase) {
+ val playbackStatesDb = mongodb.getCollection()
+ val moviesDb = mongodb.getCollection()
+ val tvShowDb = mongodb.getCollection()
+ val episodeDb = mongodb.getCollection()
+ val mediaRefsDb = mongodb.getCollection()
+ route("/home") {
+ get {
+ val session = call.principal()!!
+
+ // Currently watching
+ val playbackStates = playbackStatesDb
+ .find(PlaybackState::userId eq session.userId)
+ .sort(descending(PlaybackState::updatedAt))
+ .limit(10)
+ .toList()
+
+ val playbackStateMovies = moviesDb
+ .find(Movie::id `in` playbackStates.map(PlaybackState::mediaId))
+ .toList()
+
+ val playbackStateItems = playbackStates.associateBy { state ->
+ playbackStateMovies.first { it.id == state.mediaId }
+ }
+
+ // Recently Added Movies
+ val recentlyAddedMovies = moviesDb
+ .find()
+ .sort(descending(Movie::added))
+ .limit(20)
+ .toList()
+ val recentlyAddedRefs = mediaRefsDb
+ .find(MediaReference::contentId `in` recentlyAddedMovies.map(Movie::id))
+ .toList()
+ val recentlyAdded = recentlyAddedMovies.associateWith { movie ->
+ recentlyAddedRefs.find { it.contentId == movie.id }
+ }
+
+ val tvShows = tvShowDb
+ .find()
+ .sort(descending(TvShow::added))
+ .limit(20)
+ .toList()
+
+ // Popular movies
+ val tmdbPopular = tmdb.movies.getPopularMovies("en", 1)
+ val ids = tmdbPopular.map(MovieDb::getId)
+ val existingIds = moviesDb
+ .find(Movie::tmdbId `in` ids)
+ .toList()
+ .map(Movie::tmdbId)
+ val popularMovies = tmdbPopular.asApiResponse(existingIds).items
+ val localPopularMovies = moviesDb
+ .find(Movie::tmdbId `in` existingIds)
+ .toList()
+ val popularMediaRefs = mediaRefsDb
+ .find(MediaReference::contentId `in` localPopularMovies.map(Movie::id))
+ .toList()
+ val popularMoviesMap = popularMovies.associateWith { m ->
+ val contentId = localPopularMovies.find { it.tmdbId == m.tmdbId }?.id
+ if (contentId == null) {
+ null
+ } else {
+ popularMediaRefs.find { it.contentId == contentId }
+ }
+ }
+
+ call.respond(
+ HomeResponse(
+ currentlyWatching = playbackStateItems,
+ recentlyAdded = recentlyAdded,
+ popularMovies = popularMoviesMap,
+ recentlyAddedTv = tvShows
+ )
+ )
+ }
+ }
+}
diff --git a/server/src/main/kotlin/routes/Media.kt b/server/src/main/kotlin/routes/Media.kt
new file mode 100644
index 00000000..6bb7b1df
--- /dev/null
+++ b/server/src/main/kotlin/routes/Media.kt
@@ -0,0 +1,132 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.data.UserSession
+import anystream.models.MediaReference
+import anystream.models.Movie
+import anystream.models.Permissions.GLOBAL
+import anystream.models.Permissions.MANAGE_COLLECTION
+import anystream.models.api.ImportMedia
+import anystream.media.MediaImporter
+import anystream.util.logger
+import anystream.util.withAnyPermission
+import drewcarlson.torrentsearch.Category
+import drewcarlson.torrentsearch.TorrentSearch
+import info.movito.themoviedbapi.TmdbApi
+import io.ktor.application.call
+import io.ktor.auth.*
+import io.ktor.http.*
+import io.ktor.http.HttpStatusCode.Companion.InternalServerError
+import io.ktor.http.HttpStatusCode.Companion.NotFound
+import io.ktor.http.HttpStatusCode.Companion.OK
+import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity
+import io.ktor.http.cio.websocket.*
+import io.ktor.request.*
+import io.ktor.response.respond
+import io.ktor.routing.*
+import io.ktor.websocket.*
+import kotlinx.coroutines.flow.*
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.encodeToJsonElement
+import org.litote.kmongo.*
+import org.litote.kmongo.coroutine.CoroutineDatabase
+
+fun Route.addMediaRoutes(
+ tmdb: TmdbApi,
+ mongodb: CoroutineDatabase,
+ torrentSearch: TorrentSearch,
+ importer: MediaImporter,
+) {
+ val moviesDb = mongodb.getCollection()
+ val mediaRefs = mongodb.getCollection()
+ withAnyPermission(GLOBAL, MANAGE_COLLECTION) {
+ route("/media") {
+ post("/import") {
+ val session = call.principal()!!
+ val import = call.receiveOrNull()
+ ?: return@post call.respond(UnprocessableEntity)
+ val importAll = call.parameters["importAll"]?.toBoolean() ?: false
+
+ call.respond(
+ if (importAll) {
+ val list = importer.importAll(session.userId, import).toList()
+ // TODO: List with different types cannot be serialized yet.
+ buildJsonArray {
+ list.forEach {
+ add(anystream.json.encodeToJsonElement(it))
+ }
+ }
+ } else {
+ importer.import(session.userId, import)
+ }
+ )
+ }
+
+ post("/unmapped") {
+ val session = call.principal()!!
+ val import = call.receiveOrNull()
+ ?: return@post call.respond(UnprocessableEntity)
+
+ call.respond(importer.findUnmappedFiles(session.userId, import))
+ }
+
+ route("/tmdb") {
+ route("/{tmdb_id}") {
+ get("/sources") {
+ val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull()
+
+ if (tmdbId == null) {
+ call.respond(NotFound)
+ } else {
+ runCatching {
+ tmdb.movies.getMovie(tmdbId, null)
+ }.onSuccess { tmdbMovie ->
+ call.respond(
+ torrentSearch.search(tmdbMovie.title, Category.MOVIES, 100)
+ // TODO: API or client sort+filter
+ .sortedByDescending { it.seeds }
+ )
+ }.onFailure { e ->
+ logger.error("Error fetching movie from TMDB - tmdbId=$tmdbId", e)
+ call.respond(InternalServerError)
+ }
+ }
+ }
+ }
+ }
+
+ route("/movie/{movie_id}") {
+ get("/sources") {
+ val movieId = call.parameters["movie_id"] ?: ""
+
+ val movie = moviesDb.findOneById(movieId)
+ if (movie == null) {
+ call.respond(NotFound)
+ } else {
+ call.respond(
+ torrentSearch.search(movie.title, Category.MOVIES, 100)
+ // TODO: API or client sort+filter
+ .sortedByDescending { it.seeds }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/server/src/main/kotlin/routes/Movies.kt b/server/src/main/kotlin/routes/Movies.kt
new file mode 100644
index 00000000..4afbbf5b
--- /dev/null
+++ b/server/src/main/kotlin/routes/Movies.kt
@@ -0,0 +1,225 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.data.UserSession
+import anystream.data.asApiResponse
+import anystream.data.asMovie
+import anystream.data.asPartialMovie
+import anystream.models.MediaReference
+import anystream.models.Movie
+import anystream.models.Permissions.GLOBAL
+import anystream.models.Permissions.MANAGE_COLLECTION
+import anystream.models.api.ImportMedia
+import anystream.models.api.MoviesResponse
+import anystream.models.api.TmdbMoviesResponse
+import anystream.media.MediaImporter
+import anystream.util.logger
+import anystream.util.withAnyPermission
+import drewcarlson.torrentsearch.Category
+import drewcarlson.torrentsearch.TorrentSearch
+import info.movito.themoviedbapi.TmdbApi
+import info.movito.themoviedbapi.TmdbMovies.MovieMethod
+import info.movito.themoviedbapi.model.MovieDb
+import io.ktor.application.call
+import io.ktor.application.log
+import io.ktor.auth.*
+import io.ktor.http.*
+import io.ktor.http.HttpStatusCode.Companion.InternalServerError
+import io.ktor.http.HttpStatusCode.Companion.NotFound
+import io.ktor.http.HttpStatusCode.Companion.OK
+import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity
+import io.ktor.http.cio.websocket.*
+import io.ktor.request.*
+import io.ktor.response.respond
+import io.ktor.routing.*
+import io.ktor.websocket.*
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.*
+import org.bson.types.ObjectId
+import org.litote.kmongo.*
+import org.litote.kmongo.coroutine.CoroutineDatabase
+
+fun Route.addMovieRoutes(
+ tmdb: TmdbApi,
+ mongodb: CoroutineDatabase,
+) {
+ val moviesDb = mongodb.getCollection()
+ val mediaRefs = mongodb.getCollection()
+ route("/movies") {
+ get {
+ call.respond(
+ MoviesResponse(
+ movies = moviesDb.find().toList(),
+ mediaReferences = mediaRefs.find().toList()
+ )
+ )
+ }
+
+ route("/tmdb") {
+ get("/popular") {
+ val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
+ runCatching {
+ tmdb.movies.getPopularMovies("en", page)
+ }.onSuccess { tmdbMovies ->
+ val ids = tmdbMovies.map(MovieDb::getId)
+ val existingIds = moviesDb
+ .find(Movie::tmdbId `in` ids)
+ .toList()
+ .map(Movie::tmdbId)
+
+ call.respond(tmdbMovies.asApiResponse(existingIds))
+ }.onFailure { e ->
+ // TODO: Decompose this exception and retry where possible
+ logger.error("Error fetching popular movies from TMDB - page=$page", e)
+ call.respond(InternalServerError)
+ }
+ }
+
+ get("/search") {
+ val query = call.request.queryParameters["query"]
+ val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
+
+ if (query.isNullOrBlank()) {
+ call.respond(TmdbMoviesResponse())
+ } else {
+ runCatching {
+ tmdb.search.searchMovie(
+ query.encodeURLQueryComponent(),
+ 0,
+ null,
+ false,
+ page
+ )
+ }.onSuccess { tmdbMovies ->
+ val ids = tmdbMovies.map(MovieDb::getId)
+ val existingIds = moviesDb
+ .find(Movie::tmdbId `in` ids)
+ .toList()
+ .map(Movie::tmdbId)
+ call.respond(tmdbMovies.asApiResponse(existingIds))
+ }.onFailure { e ->
+ // TODO: Decompose this exception and retry where possible
+ logger.error("Error searching TMDB - page=$page, query='$query'", e)
+ call.respond(InternalServerError)
+ }
+ }
+ }
+
+ route("/{tmdb_id}") {
+ get {
+ val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull()
+
+ if (tmdbId == null) {
+ call.respond(NotFound)
+ } else {
+ runCatching {
+ tmdb.movies.getMovie(
+ tmdbId,
+ null,
+ MovieMethod.keywords,
+ MovieMethod.images,
+ MovieMethod.alternative_titles
+ )
+ }.onSuccess { tmdbMovie ->
+ call.respond(tmdbMovie.asPartialMovie())
+ }.onFailure { e ->
+ // TODO: Decompose this exception and retry where possible
+ logger.error("Error fetching movie from TMDB - tmdb=$tmdbId", e)
+ call.respond(InternalServerError)
+ }
+ }
+ }
+
+ withAnyPermission(GLOBAL, MANAGE_COLLECTION) {
+ get("/add") {
+ val session = call.principal()!!
+ val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull()
+
+ when {
+ tmdbId == null -> {
+ call.respond(NotFound)
+ }
+ moviesDb.findOne(Movie::tmdbId eq tmdbId) != null -> {
+ call.respond(HttpStatusCode.Conflict)
+ }
+ else -> {
+ runCatching {
+ tmdb.movies.getMovie(
+ tmdbId,
+ null,
+ MovieMethod.images,
+ MovieMethod.release_dates,
+ MovieMethod.alternative_titles,
+ MovieMethod.keywords
+ )
+ }.onSuccess { tmdbMovie ->
+ val id = ObjectId.get().toString()
+ moviesDb.insertOne(tmdbMovie.asMovie(id, session.userId))
+ call.respond(OK)
+ }.onFailure { e ->
+ logger.error(
+ "Error fetching movie from TMDB - tmdbId=$tmdbId",
+ e
+ )
+ call.respond(InternalServerError)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ route("/{movie_id}") {
+ get {
+ val movieId = call.parameters["movie_id"] ?: ""
+
+ val movie = moviesDb.findOneById(movieId)
+ if (movie == null) {
+ call.respond(NotFound)
+ } else {
+ call.respond(movie)
+ }
+ }
+
+ withAnyPermission(GLOBAL, MANAGE_COLLECTION) {
+ delete {
+ val movieId = call.parameters["movie_id"] ?: ""
+ val result = moviesDb.deleteOneById(movieId)
+ if (result.deletedCount == 0L) {
+ call.respond(NotFound)
+ } else {
+ mediaRefs.deleteMany(MediaReference::contentId eq movieId)
+ call.respond(OK)
+ }
+ }
+ }
+ }
+ }
+}
+
+fun Flow.concurrentMap(
+ scope: CoroutineScope,
+ concurrencyLevel: Int,
+ transform: suspend (T) -> R
+): Flow = this
+ .map { scope.async { transform(it) } }
+ .buffer(concurrencyLevel)
+ .map { it.await() }
diff --git a/server/src/main/kotlin/routes/Routing.kt b/server/src/main/kotlin/routes/Routing.kt
new file mode 100644
index 00000000..1803f1b5
--- /dev/null
+++ b/server/src/main/kotlin/routes/Routing.kt
@@ -0,0 +1,93 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.media.MediaImporter
+import anystream.media.processor.MovieImportProcessor
+import anystream.media.processor.TvImportProcessor
+import anystream.models.*
+import anystream.torrent.search.KMongoTorrentProviderCache
+import anystream.util.SinglePageApp
+import anystream.util.withAnyPermission
+import com.github.kokorin.jaffree.ffmpeg.FFmpeg
+import drewcarlson.qbittorrent.QBittorrentClient
+import drewcarlson.torrentsearch.TorrentSearch
+import info.movito.themoviedbapi.TmdbApi
+import io.ktor.application.*
+import io.ktor.auth.*
+import io.ktor.routing.*
+import org.litote.kmongo.coroutine.CoroutineDatabase
+import java.nio.file.Path
+
+fun Application.installRouting(mongodb: CoroutineDatabase) {
+ val frontEndPath = environment.config.property("app.frontEndPath").getString()
+ val ffmpegPath = environment.config.property("app.ffmpegPath").getString()
+ val tmdbApiKey = environment.config.property("app.tmdbApiKey").getString()
+ val qbittorrentUrl = environment.config.property("app.qbittorrentUrl").getString()
+ val qbittorrentUser = environment.config.property("app.qbittorrentUser").getString()
+ val qbittorrentPass = environment.config.property("app.qbittorrentPassword").getString()
+
+ val tmdb by lazy { TmdbApi(tmdbApiKey) }
+
+ val torrentSearch = TorrentSearch(KMongoTorrentProviderCache(mongodb))
+
+ val qbClient = QBittorrentClient(
+ baseUrl = qbittorrentUrl,
+ username = qbittorrentUser,
+ password = qbittorrentPass,
+ )
+ val ffmpeg = FFmpeg.atPath(Path.of(ffmpegPath))
+
+ val mediaRefs = mongodb.getCollection()
+
+ val processors = listOf(
+ MovieImportProcessor(tmdb, mongodb, log),
+ TvImportProcessor(tmdb, mongodb, log),
+ )
+ val importer = MediaImporter(tmdb, processors, mediaRefs, log)
+
+ routing {
+ route("/api") {
+ addUserRoutes(mongodb)
+ authenticate {
+ addHomeRoutes(tmdb, mongodb)
+ withAnyPermission(Permissions.GLOBAL, Permissions.VIEW_COLLECTION) {
+ addTvShowRoutes(tmdb, mongodb)
+ addMovieRoutes(tmdb, mongodb)
+ }
+ withAnyPermission(Permissions.GLOBAL, Permissions.TORRENT_MANAGEMENT) {
+ addTorrentRoutes(qbClient, mongodb)
+ }
+ withAnyPermission(Permissions.GLOBAL, Permissions.MANAGE_COLLECTION) {
+ addMediaRoutes(tmdb, mongodb, torrentSearch, importer)
+ }
+ }
+
+ // TODO: WS endpoint Authentication and permissions
+ addStreamRoutes(qbClient, mongodb, ffmpeg)
+ addStreamWsRoutes(qbClient, mongodb)
+ addTorrentWsRoutes(qbClient)
+ addUserWsRoutes(mongodb)
+ }
+ }
+
+ install(SinglePageApp) {
+ ignoreBasePath = "/api"
+ staticFilePath = frontEndPath
+ }
+}
diff --git a/server/src/main/kotlin/routes/Stream.kt b/server/src/main/kotlin/routes/Stream.kt
new file mode 100644
index 00000000..37590ab4
--- /dev/null
+++ b/server/src/main/kotlin/routes/Stream.kt
@@ -0,0 +1,160 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.data.UserSession
+import anystream.json
+import anystream.models.LocalMediaReference
+import anystream.models.DownloadMediaReference
+import anystream.models.MediaReference
+import anystream.models.PlaybackState
+import com.github.kokorin.jaffree.ffmpeg.*
+import drewcarlson.qbittorrent.QBittorrentClient
+import io.ktor.application.*
+import io.ktor.auth.*
+import io.ktor.http.HttpStatusCode.Companion.NotFound
+import io.ktor.http.HttpStatusCode.Companion.OK
+import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity
+import io.ktor.http.cio.websocket.*
+import io.ktor.http.cio.websocket.Frame
+import io.ktor.http.content.*
+import io.ktor.request.*
+import io.ktor.response.*
+import io.ktor.routing.*
+import io.ktor.sessions.*
+import io.ktor.websocket.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import org.bson.types.ObjectId
+import org.litote.kmongo.coroutine.CoroutineDatabase
+import org.litote.kmongo.coroutine.replaceOne
+import org.litote.kmongo.eq
+import java.io.File
+import java.time.Instant
+import java.util.concurrent.ConcurrentHashMap
+import java.util.*
+
+
+fun Route.addStreamRoutes(
+ qbClient: QBittorrentClient,
+ mongodb: CoroutineDatabase,
+ ffmpeg: FFmpeg,
+) {
+ val transcodePath = application.environment.config.property("app.transcodePath").getString()
+ val playbackStateDb = mongodb.getCollection()
+ val mediaRefs = mongodb.getCollection()
+ route("/stream") {
+ route("/{media_ref_id}") {
+ route("/state") {
+ get {
+ val session = call.principal()!!
+ val mediaRefId = call.parameters["media_ref_id"]!!
+ val state = playbackStateDb.findOne(
+ PlaybackState::userId eq session.userId,
+ PlaybackState::mediaReferenceId eq mediaRefId
+ )
+ call.respond(state ?: NotFound)
+ }
+ put {
+ val session = call.principal()!!
+ val mediaRefId = call.parameters["media_ref_id"]!!
+ val state = call.receiveOrNull()
+ ?: return@put call.respond(UnprocessableEntity)
+
+ playbackStateDb.deleteOne(
+ PlaybackState::userId eq session.userId,
+ PlaybackState::mediaReferenceId eq mediaRefId
+ )
+ playbackStateDb.insertOne(state)
+ call.respond(OK)
+ }
+ }
+
+ val videoFileCache = ConcurrentHashMap()
+ @Suppress("BlockingMethodInNonBlockingContext")
+ get("/direct") {
+ val mediaRefId = call.parameters["media_ref_id"]!!
+ val file = if (videoFileCache.containsKey(mediaRefId)) {
+ videoFileCache[mediaRefId]!!
+ } else {
+ val mediaRef = mediaRefs.find(MediaReference::id eq mediaRefId).first()
+ ?: return@get call.respond(NotFound)
+
+ when (mediaRef) {
+ is LocalMediaReference -> mediaRef.filePath
+ is DownloadMediaReference -> mediaRef.filePath
+ }?.run(::File)
+ } ?: return@get call.respond(NotFound)
+
+ if (file.name.endsWith(".mp4")) {
+ videoFileCache.putIfAbsent(mediaRefId, file)
+ call.respond(LocalFileContent(file))
+ } else {
+ val name = UUID.randomUUID().toString()
+ val outfile = File(transcodePath, "$name.mp4")
+
+ ffmpeg.addInput(UrlInput.fromPath(file.toPath()))
+ .addOutput(UrlOutput.toPath(outfile.toPath()).copyAllCodecs())
+ .setOverwriteOutput(true)
+ .execute()
+ videoFileCache.putIfAbsent(mediaRefId, outfile)
+ call.respond(LocalFileContent(outfile))
+ }
+ }
+ }
+ }
+}
+
+fun Route.addStreamWsRoutes(
+ qbClient: QBittorrentClient,
+ mongodb: CoroutineDatabase
+) {
+ val playbackStateDb = mongodb.getCollection()
+ val mediaRefs = mongodb.getCollection()
+
+ webSocket("/ws/stream/{media_ref_id}/state") {
+ val userId = (incoming.receive() as Frame.Text).readText()
+ val mediaRefId = call.parameters["media_ref_id"]!!
+ val mediaRef = mediaRefs.findOneById(mediaRefId)!!
+ val state = playbackStateDb.findOne(
+ PlaybackState::userId eq userId,
+ PlaybackState::mediaReferenceId eq mediaRefId
+ ) ?: PlaybackState(
+ id = ObjectId.get().toString(),
+ mediaReferenceId = mediaRefId,
+ position = 0,
+ userId = userId,
+ mediaId = mediaRef.contentId,
+ updatedAt = Instant.now().toEpochMilli()
+ ).also {
+ playbackStateDb.insertOne(it)
+ }
+
+ send(Frame.Text(json.encodeToString(state)))
+
+ incoming.receiveAsFlow()
+ .takeWhile { !outgoing.isClosedForSend }
+ .filterIsInstance()
+ .collect { frame ->
+ val newState = json.decodeFromString(frame.readText())
+ playbackStateDb.replaceOne(newState.copy(updatedAt = Instant.now().toEpochMilli()))
+ }
+ }
+}
diff --git a/server/src/main/kotlin/routes/Torrents.kt b/server/src/main/kotlin/routes/Torrents.kt
new file mode 100644
index 00000000..c46e7561
--- /dev/null
+++ b/server/src/main/kotlin/routes/Torrents.kt
@@ -0,0 +1,193 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.data.UserSession
+import anystream.json
+import anystream.models.DownloadMediaReference
+import anystream.models.MediaReference
+import anystream.models.MediaKind
+import anystream.torrent.search.TorrentDescription2
+import drewcarlson.qbittorrent.QBittorrentClient
+import drewcarlson.qbittorrent.models.Torrent
+import drewcarlson.qbittorrent.models.TorrentFile
+import io.ktor.application.*
+import io.ktor.auth.*
+import io.ktor.http.HttpStatusCode.Companion.Conflict
+import io.ktor.http.HttpStatusCode.Companion.OK
+import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity
+import io.ktor.http.cio.websocket.*
+import io.ktor.request.*
+import io.ktor.response.*
+import io.ktor.routing.*
+import io.ktor.websocket.*
+import kotlinx.coroutines.flow.*
+import kotlinx.serialization.encodeToString
+import org.bson.types.ObjectId
+import org.litote.kmongo.coroutine.CoroutineDatabase
+import org.litote.kmongo.eq
+import java.time.Instant
+
+fun Route.addTorrentRoutes(qbClient: QBittorrentClient, mongodb: CoroutineDatabase) {
+ val mediaRefs = mongodb.getCollection()
+ route("/torrents") {
+ get {
+ call.respond(qbClient.getTorrents())
+ }
+
+ post {
+ val session = call.principal()!!
+ val description = call.receiveOrNull()
+ ?: return@post call.respond(UnprocessableEntity)
+ if (qbClient.getTorrentProperties(description.hash) != null) {
+ return@post call.respond(Conflict)
+ }
+ qbClient.addTorrent {
+ urls.add(description.magnetUrl)
+ savePath = "/downloads"
+ sequentialDownload = true
+ firstLastPiecePriority = true
+ }
+ call.respond(OK)
+
+ val movieId = call.parameters["movieId"] ?: return@post
+ val downloadId = ObjectId.get().toString()
+ mediaRefs.insertOne(
+ DownloadMediaReference(
+ id = downloadId,
+ contentId = movieId,
+ hash = description.hash,
+ addedByUserId = session.userId,
+ added = Instant.now().toEpochMilli(),
+ fileIndex = null,
+ filePath = null,
+ mediaKind = MediaKind.MOVIE,
+ )
+ )
+ qbClient.torrentFlow(description.hash)
+ .dropWhile { it.state == Torrent.State.META_DL }
+ .mapNotNull { torrent ->
+ qbClient.getTorrentFiles(torrent.hash)
+ .filter(videoFile)
+ .maxByOrNull(TorrentFile::size)
+ ?.run { this to torrent }
+ }
+ .take(1)
+ .onEach { (file, torrent) ->
+ val download = mediaRefs.findOneById(downloadId) as DownloadMediaReference
+ mediaRefs.updateOneById(
+ downloadId,
+ download.copy(
+ fileIndex = file.id,
+ filePath = "${torrent.savePath}/${file.name}"
+ )
+ )
+ }
+ .launchIn(application)
+ }
+
+ route("/global") {
+ get {
+ call.respond(qbClient.getGlobalTransferInfo())
+ }
+ }
+
+ route("/{hash}") {
+ get("/files") {
+ val hash = call.parameters["hash"]!!
+ call.respond(qbClient.getTorrentFiles(hash))
+ }
+
+ get("/pause") {
+ val hash = call.parameters["hash"]!!
+ qbClient.pauseTorrents(listOf(hash))
+ call.respond(OK)
+ }
+
+ get("/resume") {
+ val hash = call.parameters["hash"]!!
+ qbClient.resumeTorrents(listOf(hash))
+ call.respond(OK)
+ }
+
+ delete {
+ val hash = call.parameters["hash"]!!
+ val deleteFiles = call.request.queryParameters["deleteFiles"]!!.toBoolean()
+ qbClient.deleteTorrents(listOf(hash), deleteFiles = deleteFiles)
+ mediaRefs.deleteOne(DownloadMediaReference::hash eq hash)
+ call.respond(OK)
+ }
+ }
+
+ /*route("/quickstart") {
+ get("/tmdb/{tmdb_id}") {
+ val tmdbId = call.parameters["tmdb_id"]!!.toInt()
+ val movie = tmdb.movies.getMovie(tmdbId, null)
+ val results = torrentSearch.search(movie.title, Category.MOVIES)
+ // TODO: Better quickstart behavior, consider file size and transcoding reqs
+ val selection = results.maxByOrNull { it.seeds }!!
+
+ if (qbClient.getTorrents().any { it.hash == selection.hash }) {
+ call.respond(selection.hash)
+ } else {
+ qbClient.addTorrent {
+ urls.add(selection.magnetUrl)
+ savePath = "/downloads"
+ category = "movies"
+ rootFolder = true
+ sequentialDownload = true
+ firstLastPiecePriority = true
+ }
+ call.respond(selection.hash)
+ }
+ }
+ }*/
+ }
+}
+
+fun Route.addTorrentWsRoutes(qbClient: QBittorrentClient) {
+ webSocket("/ws/torrents/observe") {
+ qbClient.syncMainData()
+ .takeWhile { !outgoing.isClosedForSend }
+ .collect { data ->
+ val changed = data.torrents.keys
+ val removed = data.torrentsRemoved
+ if (changed.isNotEmpty() || removed.isNotEmpty()) {
+ val listText = (changed + removed)
+ .distinct()
+ .joinToString(",")
+ send(Frame.Text(listText))
+ }
+ }
+ }
+ webSocket("/ws/torrents/global") {
+ qbClient.syncMainData()
+ .takeWhile { !outgoing.isClosedForSend }
+ .collect { data ->
+ outgoing.send(Frame.Text(json.encodeToString(data.serverState)))
+ }
+ }
+}
+
+private val videoExtensions = listOf(".mp4", ".avi", ".mkv")
+private val videoFile = { torrentFile: TorrentFile ->
+ torrentFile.name.run {
+ videoExtensions.any { endsWith(it) } && !contains("sample", true)
+ }
+}
+
diff --git a/server/src/main/kotlin/routes/TvShows.kt b/server/src/main/kotlin/routes/TvShows.kt
new file mode 100644
index 00000000..4631d3be
--- /dev/null
+++ b/server/src/main/kotlin/routes/TvShows.kt
@@ -0,0 +1,107 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.data.asApiResponse
+import anystream.data.asCompleteTvSeries
+import anystream.models.Episode
+import anystream.models.MediaReference
+import anystream.models.TvShow
+import anystream.models.api.TmdbTvShowResponse
+import anystream.util.logger
+import info.movito.themoviedbapi.TmdbApi
+import info.movito.themoviedbapi.TmdbTV.TvMethod
+import io.ktor.application.call
+import io.ktor.http.HttpStatusCode
+import io.ktor.response.respond
+import io.ktor.routing.*
+import org.litote.kmongo.coroutine.CoroutineDatabase
+
+fun Route.addTvShowRoutes(
+ tmdb: TmdbApi,
+ mongodb: CoroutineDatabase,
+) {
+ val tvShowDb = mongodb.getCollection()
+ val episodeDb = mongodb.getCollection()
+ val mediaRefs = mongodb.getCollection()
+ route("/tv") {
+ get {
+ call.respond(tvShowDb.find().toList())
+ }
+
+ route("/tmdb") {
+ get("/popular") {
+ val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
+ runCatching {
+ tmdb.tvSeries.getPopular("en", page)
+ }.onSuccess { tmdbShows ->
+ call.respond(tmdbShows.asApiResponse())
+ }.onFailure { e ->
+ // TODO: Decompose this exception and retry where possible
+ logger.error("Error fetching popular series from TMDB - page=$page", e)
+ call.respond(HttpStatusCode.InternalServerError)
+ }
+ }
+
+ get("/{tmdb_id}") {
+ val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull()
+
+ if (tmdbId == null) {
+ call.respond(HttpStatusCode.NotFound)
+ } else {
+ runCatching {
+ tmdb.tvSeries.getSeries(
+ tmdbId,
+ null,
+ TvMethod.keywords
+ )
+ }.onSuccess { tmdbSeries ->
+ call.respond(tmdbSeries.asCompleteTvSeries())
+ }.onFailure { e ->
+ // TODO: Decompose this exception and retry where possible
+ logger.error("Error fetching series from TMDB - tmdb=$tmdbId", e)
+ call.respond(HttpStatusCode.InternalServerError)
+ }
+ }
+ }
+
+ get("/search") {
+ val query = call.request.queryParameters["query"]
+ val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
+
+ if (query.isNullOrBlank()) {
+ call.respond(TmdbTvShowResponse())
+ } else {
+ runCatching {
+ tmdb.search.searchTv(query, null, page)
+ }.onSuccess { tmdbShows ->
+ call.respond(tmdbShows.asApiResponse())
+ }.onFailure { e ->
+ // TODO: Decompose this exception and retry where possible
+ logger.error("Error searching TMDB - page=$page, query='$query'", e)
+ call.respond(HttpStatusCode.InternalServerError)
+ }
+ }
+ }
+ }
+
+ get("/{show_id}") {
+ val showId = call.parameters["show_id"] ?: ""
+ }
+ }
+}
diff --git a/server/src/main/kotlin/routes/Users.kt b/server/src/main/kotlin/routes/Users.kt
new file mode 100644
index 00000000..e020a914
--- /dev/null
+++ b/server/src/main/kotlin/routes/Users.kt
@@ -0,0 +1,376 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.routes
+
+import anystream.data.UserSession
+import anystream.json
+import anystream.models.*
+import anystream.models.api.*
+import anystream.models.api.CreateSessionError.*
+import anystream.models.api.CreateUserError.PasswordError
+import anystream.models.api.CreateUserError.UsernameError
+import anystream.util.logger
+import anystream.util.withAnyPermission
+import com.mongodb.MongoQueryException
+import io.ktor.application.call
+import io.ktor.auth.*
+import io.ktor.http.HttpStatusCode.Companion.BadRequest
+import io.ktor.http.HttpStatusCode.Companion.Forbidden
+import io.ktor.http.HttpStatusCode.Companion.InternalServerError
+import io.ktor.http.HttpStatusCode.Companion.NotFound
+import io.ktor.http.HttpStatusCode.Companion.OK
+import io.ktor.http.HttpStatusCode.Companion.Unauthorized
+import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity
+import io.ktor.http.cio.websocket.*
+import io.ktor.request.receiveOrNull
+import io.ktor.response.respond
+import io.ktor.routing.*
+import io.ktor.sessions.*
+import io.ktor.websocket.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.serialization.encodeToString
+import org.bouncycastle.crypto.generators.BCrypt
+import org.bouncycastle.util.encoders.Hex
+import org.bson.types.ObjectId
+import org.litote.kmongo.coroutine.*
+import org.litote.kmongo.eq
+import java.time.Instant
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.random.Random
+import kotlin.reflect.jvm.internal.impl.protobuf.Internal
+import kotlin.time.seconds
+
+private const val SALT_BYTES = 128 / 8
+private const val BCRYPT_COST = 10
+private const val INVITE_CODE_BYTES = 32
+private const val PAIRING_SESSION_SECONDS = 60
+
+private val pairingCodes = ConcurrentHashMap()
+
+fun Route.addUserRoutes(mongodb: CoroutineDatabase) {
+ val users = mongodb.getCollection()
+ val credentialsDb = mongodb.getCollection()
+ val inviteCodeDb = mongodb.getCollection()
+ route("/users") {
+
+ post {
+ val body = call.receiveOrNull()
+ ?: return@post call.respond(UnprocessableEntity)
+ val createSession = call.parameters["createSession"]?.toBoolean() ?: true
+
+ val usernameError = when {
+ body.username.isBlank() -> UsernameError.BLANK
+ body.username.length < USERNAME_LENGTH_MIN -> UsernameError.TOO_SHORT
+ body.username.length > USERNAME_LENGTH_MAX -> UsernameError.TOO_LONG
+ else -> null
+ }
+ val passwordError = when {
+ body.password.isBlank() -> PasswordError.BLANK
+ body.password.length < PASSWORD_LENGTH_MIN -> PasswordError.TOO_SHORT
+ body.password.length > PASSWORD_LENGTH_MAX -> PasswordError.TOO_LONG
+ else -> null
+ }
+
+ if (usernameError != null || passwordError != null) {
+ return@post call.respond(CreateUserResponse.error(usernameError, passwordError))
+ }
+
+ val username = body.username.toLowerCase(Locale.ROOT)
+ if (users.findOne(User::username eq username) != null) {
+ return@post call.respond(
+ CreateUserResponse.error(
+ UsernameError.ALREADY_EXISTS,
+ null
+ )
+ )
+ }
+
+ val id = body.inviteCode
+ val inviteCode = if (id.isNullOrBlank()) {
+ null
+ } else {
+ inviteCodeDb.findOneById(id)
+ }
+
+ if (inviteCode == null && users.countDocuments() > 0L) {
+ return@post call.respond(Forbidden)
+ }
+
+ val permissions = inviteCode?.permissions ?: setOf(Permissions.GLOBAL)
+
+ val user = User(
+ id = ObjectId.get().toString(),
+ username = username,
+ displayName = body.username
+ )
+
+ val salt = Random.nextBytes(SALT_BYTES)
+ val passwordBytes = body.password.toByteArray()
+ val hashedPassword = BCrypt.generate(passwordBytes, salt, BCRYPT_COST)
+
+ val credentials = UserCredentials(
+ id = user.id,
+ password = hashedPassword.toUtf8Hex(),
+ salt = salt.toUtf8Hex(),
+ permissions = permissions
+ )
+ try {
+ // TODO: Ensure all or clear completed
+ users.insertOne(user)
+ credentialsDb.insertOne(credentials)
+ if (inviteCode != null) {
+ inviteCodeDb.deleteOneById(inviteCode.value)
+ }
+ if (createSession) {
+ call.sessions.getOrSet {
+ UserSession(userId = user.id, credentials.permissions)
+ }
+ }
+
+ call.respond(CreateUserResponse.success(user, credentials.permissions))
+ } catch (e: MongoQueryException) {
+ logger.error("Failed to insert new user", e)
+ call.respond(InternalServerError)
+ }
+ }
+
+ authenticate {
+ withAnyPermission(Permissions.GLOBAL) {
+ route("/invite") {
+ get {
+ val session = call.sessions.get()!!
+
+ val codes = if (session.permissions.contains(Permissions.GLOBAL)) {
+ inviteCodeDb.find().toList()
+ } else {
+ inviteCodeDb
+ .find(InviteCode::createdByUserId eq session.userId)
+ .toList()
+ }
+ call.respond(codes)
+ }
+
+ post {
+ val session = call.sessions.get()!!
+ val permissions = call.receiveOrNull()
+ ?: setOf(Permissions.VIEW_COLLECTION)
+
+ val inviteCode = InviteCode(
+ value = Hex.toHexString(Random.nextBytes(INVITE_CODE_BYTES)),
+ permissions = permissions,
+ createdByUserId = session.userId
+ )
+
+ inviteCodeDb.insertOne(inviteCode)
+ call.respond(inviteCode)
+ }
+
+ delete("/{invite_code}") {
+ val session = call.sessions.get()!!
+ val inviteCodeId = call.parameters["invite_code"]
+ ?: return@delete call.respond(BadRequest)
+
+ val result = if (session.permissions.contains(Permissions.GLOBAL)) {
+ inviteCodeDb.deleteOneById(inviteCodeId)
+ } else {
+ inviteCodeDb.deleteOne(
+ InviteCode::value eq inviteCodeId,
+ InviteCode::createdByUserId eq session.userId
+ )
+ }
+ call.respond(if (result.deletedCount == 0L) NotFound else OK)
+ }
+ }
+ }
+ }
+
+ route("/session") {
+ authenticate(optional = true) {
+ post {
+ val body = call.receiveOrNull()
+ ?: return@post call.respond(UnprocessableEntity)
+
+ if (body.username.run { isBlank() || length !in USERNAME_LENGTH_MIN..USERNAME_LENGTH_MAX }) {
+ return@post call.respond(CreateSessionResponse.error(USERNAME_INVALID))
+ }
+
+ val username = body.username.toLowerCase(Locale.ROOT)
+ if (pairingCodes.containsKey(body.password)) {
+ val session = call.principal()
+ ?: return@post call.respond(CreateSessionResponse.error(USERNAME_INVALID))
+
+ val user = users
+ .findOne(User::username eq username)
+ ?: return@post call.respond(NotFound)
+ return@post if (session.userId == user.id) {
+ pairingCodes[body.password] = PairingMessage.Authorized(
+ secret = Random.nextBytes(28).toUtf8Hex(),
+ userId = session.userId
+ )
+ call.respond(CreateSessionResponse.success(user, session.permissions))
+ } else {
+ pairingCodes[body.password] = PairingMessage.Failed
+ call.respond(CreateSessionResponse.error(PASSWORD_INCORRECT))
+ }
+ }
+
+ if (body.password.run { isBlank() || length !in PASSWORD_LENGTH_MIN..PASSWORD_LENGTH_MAX }) {
+ return@post call.respond(CreateSessionResponse.error(PASSWORD_INVALID))
+ }
+
+ val user = users.findOne(User::username eq username)
+ ?: return@post call.respond(CreateSessionResponse.error(USERNAME_NOT_FOUND))
+ val auth = credentialsDb.findOne(UserCredentials::id eq user.id)
+ ?: return@post call.respond(InternalServerError)
+
+ val saltBytes = auth.salt.utf8HexToBytes()
+ val passwordBytes = body.password.toByteArray()
+ val hashedPassword =
+ BCrypt.generate(passwordBytes, saltBytes, BCRYPT_COST).toUtf8Hex()
+
+ if (hashedPassword == auth.password) {
+ call.sessions.set(UserSession(user.id, auth.permissions))
+ call.respond(CreateSessionResponse.success(user, auth.permissions))
+ } else {
+ call.respond(CreateSessionResponse.error(PASSWORD_INCORRECT))
+ }
+ }
+ }
+
+ post("/paired") {
+ val pairingCode = call.parameters["pairingCode"]!!
+ val secret = call.parameters["secret"]!!
+
+ val pairingMessage = pairingCodes.remove(pairingCode)
+ if (pairingMessage == null || pairingMessage !is PairingMessage.Authorized) {
+ return@post call.respond(NotFound)
+ } else {
+ if (pairingMessage.secret == secret) {
+ val user = users.findOneById(pairingMessage.userId)!!
+ val userCredentials = credentialsDb.findOneById(pairingMessage.userId)!!
+ call.sessions.set(UserSession(user.id, userCredentials.permissions))
+ call.respond(
+ CreateSessionResponse.success(
+ user,
+ userCredentials.permissions
+ )
+ )
+ } else {
+ call.respond(Unauthorized)
+ }
+ }
+ }
+
+ authenticate {
+ delete {
+ call.sessions.clear()
+ call.respond(OK)
+ }
+ }
+ }
+
+ authenticate {
+ withAnyPermission(Permissions.GLOBAL) {
+ get {
+ call.respond(users.find().toList())
+ }
+ }
+
+ route("/{user_id}") {
+ withAnyPermission(Permissions.GLOBAL) {
+ get {
+ val userId = call.parameters["user_id"]!!
+ call.respond(users.findOneById(userId) ?: NotFound)
+ }
+ }
+
+ put {
+ val session = call.sessions.get()!!
+ val userId = call.parameters["user_id"]!!
+ val body = call.receiveOrNull()
+ ?: return@put call.respond(UnprocessableEntity)
+
+ if (userId == session.userId) {
+ val user = users.findOneById(userId)
+ ?: return@put call.respond(NotFound)
+ val updatedUser = user.copy(
+ displayName = body.displayName
+ )
+ users.updateOneById(userId, updatedUser)
+ // TODO: Update password
+ call.respond(OK)
+ } else {
+ call.respond(InternalServerError)
+ }
+ }
+
+ withAnyPermission(Permissions.GLOBAL) {
+ delete {
+ val userId = call.parameters["user_id"]!!
+
+ if (ObjectId.isValid(userId)) {
+ val result = users.deleteOneById(userId)
+ credentialsDb.deleteOneById(userId)
+ call.respond(if (result.deletedCount == 0L) NotFound else OK)
+ } else {
+ call.respond(BadRequest)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fun Route.addUserWsRoutes(
+ mongodb: CoroutineDatabase,
+) {
+ webSocket("/ws/users/pair") {
+ val pairingCode = UUID.randomUUID().toString().toLowerCase(Locale.ROOT)
+ val startingJson = json.encodeToString(PairingMessage.Started(pairingCode))
+ send(Frame.Text(startingJson))
+
+ pairingCodes[pairingCode] = PairingMessage.Idle
+
+ var tick = 0
+ var finalMessage: PairingMessage = PairingMessage.Idle
+ while (finalMessage == PairingMessage.Idle) {
+ delay(1.seconds)
+ tick++
+ finalMessage = pairingCodes[pairingCode] ?: return@webSocket close()
+ println(pairingCodes.toList())
+ if (tick >= PAIRING_SESSION_SECONDS) {
+ send(Frame.Text(json.encodeToString(PairingMessage.Failed)))
+ return@webSocket close()
+ }
+ }
+
+ val finalJson = json.encodeToString(finalMessage)
+ send(Frame.Text(finalJson))
+ close()
+ }
+}
+
+private fun String.utf8HexToBytes(): ByteArray =
+ toByteArray().run(Hex::decode)
+
+private fun ByteArray.toUtf8Hex(): String =
+ run(Hex::encode).toString(Charsets.UTF_8)
+
diff --git a/server/src/main/kotlin/torrent/search/KMongoTorrentProviderCache.kt b/server/src/main/kotlin/torrent/search/KMongoTorrentProviderCache.kt
new file mode 100644
index 00000000..87a055f5
--- /dev/null
+++ b/server/src/main/kotlin/torrent/search/KMongoTorrentProviderCache.kt
@@ -0,0 +1,106 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.torrent.search
+
+import com.mongodb.client.model.IndexOptions
+import com.mongodb.client.model.UpdateOptions
+import drewcarlson.torrentsearch.Category
+import drewcarlson.torrentsearch.TorrentDescription
+import drewcarlson.torrentsearch.TorrentProvider
+import drewcarlson.torrentsearch.TorrentProviderCache
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.Serializable
+import org.apache.commons.codec.binary.Hex
+import org.litote.kmongo.coroutine.CoroutineDatabase
+import org.litote.kmongo.eq
+import java.security.MessageDigest
+import java.time.Instant
+import java.util.concurrent.TimeUnit
+
+@Serializable
+private data class CacheDoc(
+ val key: String,
+ val results: List,
+ @Contextual
+ val createdAt: Instant = Instant.now()
+)
+
+private const val TOKEN_COLLECTION = "torrent-token-cache"
+private const val RESULT_COLLECTION = "torrent-result-cache"
+
+@Serializable
+data class Token(val value: String)
+
+class KMongoTorrentProviderCache(
+ mongo: CoroutineDatabase,
+ expirationMinutes: Long = 15
+) : TorrentProviderCache {
+
+ private val hash = MessageDigest.getInstance("SHA-256")
+ private val tokenCollection = mongo.getCollection(TOKEN_COLLECTION)
+ private val torrentCollection = mongo.getCollection(RESULT_COLLECTION)
+
+ init {
+ runBlocking {
+ torrentCollection.ensureIndex(
+ "{'key':1}", // CacheDoc::key
+ IndexOptions()
+ .expireAfter(expirationMinutes, TimeUnit.MINUTES)
+ )
+ }
+ }
+
+ override fun saveToken(provider: TorrentProvider, token: String) {
+ runBlocking {
+ tokenCollection.updateOneById(provider.name, token, UpdateOptions().upsert(true))
+ }
+ }
+
+ override fun loadToken(provider: TorrentProvider): String? {
+ return runBlocking {
+ tokenCollection.findOneById(provider.name)?.value
+ }
+ }
+
+ override fun saveResults(
+ provider: TorrentProvider,
+ query: String,
+ category: Category,
+ results: List
+ ) {
+ val key = cacheKey(provider, query, category)
+ runBlocking {
+ torrentCollection.insertOne(CacheDoc(key, results))
+ }
+ }
+
+ override fun loadResults(provider: TorrentProvider, query: String, category: Category): List? {
+ val key = cacheKey(provider, query, category)
+ return runBlocking {
+ torrentCollection.findOne(CacheDoc::key eq key)?.results
+ }
+ }
+
+
+ private fun cacheKey(provider: TorrentProvider, query: String, category: Category): String {
+ val raw = "${provider.name}:$query:${category.name}"
+ hash.update(raw.toByteArray())
+ return Hex.encodeHexString(hash.digest())
+ }
+}
diff --git a/server/src/main/kotlin/util/CallLogger.kt b/server/src/main/kotlin/util/CallLogger.kt
new file mode 100644
index 00000000..3eb4c63c
--- /dev/null
+++ b/server/src/main/kotlin/util/CallLogger.kt
@@ -0,0 +1,26 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.util
+
+import io.ktor.application.ApplicationCall
+import io.ktor.application.call
+import io.ktor.application.log
+import io.ktor.util.pipeline.PipelineContext
+
+val PipelineContext.logger
+ get() = this.call.application.log
diff --git a/server/src/main/kotlin/util/MongoSessionStorage.kt b/server/src/main/kotlin/util/MongoSessionStorage.kt
new file mode 100644
index 00000000..6601ec83
--- /dev/null
+++ b/server/src/main/kotlin/util/MongoSessionStorage.kt
@@ -0,0 +1,100 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.util
+
+import com.mongodb.MongoQueryException
+import com.mongodb.client.model.UpdateOptions
+import io.ktor.sessions.*
+import io.ktor.utils.io.*
+import io.ktor.utils.io.core.*
+import kotlinx.coroutines.coroutineScope
+import kotlinx.serialization.Serializable
+import org.litote.kmongo.coroutine.CoroutineDatabase
+import org.litote.kmongo.eq
+import org.slf4j.Logger
+import org.slf4j.MarkerFactory
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.NoSuchElementException
+import kotlin.text.Charsets.UTF_8
+
+@Serializable
+private class SessionData(
+ val id: String,
+ val data: ByteArray,
+)
+
+class MongoSessionStorage(
+ mongodb: CoroutineDatabase,
+ private val logger: Logger,
+) : SimplifiedSessionStorage() {
+
+ private val marker = MarkerFactory.getMarker(this::class.simpleName)
+
+ private val sessions = ConcurrentHashMap()
+ private val sessionCollection = mongodb.getCollection()
+ private val updateOptions = UpdateOptions().upsert(true)
+
+ override suspend fun write(id: String, data: ByteArray?) {
+ if (data == null) {
+ logger.trace(marker, "Deleting session $id")
+ sessions.remove(id)
+ sessionCollection.deleteOne(SessionData::id eq id)
+ } else {
+ logger.debug(marker, "Writing session $id, ${data.toString(UTF_8)}")
+ sessions[id] = data
+ try {
+ sessionCollection.updateOne(
+ SessionData::id eq id,
+ SessionData(id, data),
+ updateOptions
+ )
+ } catch (e: MongoQueryException) {
+ logger.trace(marker, "Failed to write session data", e)
+ }
+ }
+ }
+
+ override suspend fun read(id: String): ByteArray? {
+ logger.debug(marker, "Looking for session $id")
+ return (sessions[id] ?: sessionCollection.findOne(SessionData::id eq id)?.data).also {
+ logger.debug(marker, "Found session $id")
+ }
+ }
+}
+
+abstract class SimplifiedSessionStorage : SessionStorage {
+ abstract suspend fun read(id: String): ByteArray?
+ abstract suspend fun write(id: String, data: ByteArray?)
+
+ override suspend fun invalidate(id: String) {
+ write(id, null)
+ }
+
+ override suspend fun read(id: String, consumer: suspend (ByteReadChannel) -> R): R {
+ val data = read(id) ?: throw NoSuchElementException("Session $id not found")
+ return consumer(ByteReadChannel(data))
+ }
+
+ override suspend fun write(id: String, provider: suspend (ByteWriteChannel) -> Unit) {
+ return coroutineScope {
+ provider(reader(autoFlush = true) {
+ write(id, channel.readRemaining().readBytes())
+ }.channel)
+ }
+ }
+}
diff --git a/server/src/main/kotlin/util/PermissionAuthorization.kt b/server/src/main/kotlin/util/PermissionAuthorization.kt
new file mode 100644
index 00000000..ea890ec1
--- /dev/null
+++ b/server/src/main/kotlin/util/PermissionAuthorization.kt
@@ -0,0 +1,133 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.util
+
+import io.ktor.application.*
+import io.ktor.auth.*
+import io.ktor.http.HttpStatusCode.Companion.Forbidden
+import io.ktor.request.*
+import io.ktor.response.*
+import io.ktor.routing.*
+import io.ktor.util.*
+import io.ktor.util.pipeline.*
+
+typealias Permission = String
+
+class AuthorizationException(override val message: String) : Exception(message)
+
+class PermissionAuthorization {
+
+ private var extractPermissions: (Principal) -> Set = { emptySet() }
+
+ fun extract(body: (Principal) -> Set) {
+ extractPermissions = body
+ }
+
+ fun interceptPipeline(
+ pipeline: ApplicationCallPipeline,
+ any: Set? = null,
+ all: Set? = null,
+ none: Set? = null
+ ) {
+ pipeline.insertPhaseAfter(ApplicationCallPipeline.Features, Authentication.ChallengePhase)
+ pipeline.insertPhaseAfter(Authentication.ChallengePhase, AuthorizationPhase)
+
+ pipeline.intercept(AuthorizationPhase) {
+ val principal = call.authentication.principal()
+ ?: throw AuthorizationException("Missing principal")
+ val activePermissions = extractPermissions(principal)
+ val denyReasons = mutableListOf()
+ all?.let {
+ val missing = all - activePermissions
+ if (missing.isNotEmpty()) {
+ denyReasons += "Principal $principal is missing required permission(s) ${missing.joinToString(" and ")}"
+ }
+ }
+ any?.let {
+ if (any.none { it in activePermissions }) {
+ denyReasons += "Principal $principal is missing all possible permission(s) ${any.joinToString(" or ")}"
+ }
+ }
+ none?.let {
+ if (none.any { it in activePermissions }) {
+ denyReasons += "Principal $principal has excluded permission(s) ${(none.intersect(activePermissions)).joinToString(" and ")}"
+ }
+ }
+ if (denyReasons.isNotEmpty()) {
+ val message = denyReasons.joinToString(". ")
+ logger.warn("Authorization failed for ${call.request.path()}. $message")
+ call.respond(Forbidden)
+ finish()
+ }
+ }
+ }
+
+
+ companion object Feature :
+ ApplicationFeature {
+ override val key = AttributeKey("PermissionAuthorization")
+
+ val AuthorizationPhase = PipelinePhase("PermissionAuthorization")
+
+ override fun install(
+ pipeline: ApplicationCallPipeline,
+ configure: PermissionAuthorization.() -> Unit
+ ): PermissionAuthorization {
+ return PermissionAuthorization().also(configure)
+ }
+ }
+}
+
+class AuthorizedRouteSelector(private val description: String) : RouteSelector() {
+
+ override fun evaluate(context: RoutingResolveContext, segmentIndex: Int) =
+ RouteSelectorEvaluation.Constant
+
+ override fun toString(): String = "(authorize ${description})"
+}
+
+fun Route.withPermission(permission: Permission, build: Route.() -> Unit) =
+ authorizedRoute(all = setOf(permission), build = build)
+
+fun Route.withAllPermissions(vararg permissions: Permission, build: Route.() -> Unit) =
+ authorizedRoute(all = permissions.toSet(), build = build)
+
+fun Route.withAnyPermission(vararg permissions: Permission, build: Route.() -> Unit) =
+ authorizedRoute(any = permissions.toSet(), build = build)
+
+fun Route.withoutPermissions(vararg permissions: Permission, build: Route.() -> Unit) =
+ authorizedRoute(none = permissions.toSet(), build = build)
+
+private fun Route.authorizedRoute(
+ any: Set? = null,
+ all: Set? = null,
+ none: Set? = null,
+ build: Route.() -> Unit
+): Route {
+ val description = listOfNotNull(
+ any?.let { "anyOf (${any.joinToString(" ")})" },
+ all?.let { "allOf (${all.joinToString(" ")})" },
+ none?.let { "noneOf (${none.joinToString(" ")})" }
+ ).joinToString(",")
+ return createChild(AuthorizedRouteSelector(description)).also { route ->
+ application
+ .feature(PermissionAuthorization)
+ .interceptPipeline(route, any, all, none)
+ route.build()
+ }
+}
diff --git a/server/src/main/kotlin/util/SinglePageApp.kt b/server/src/main/kotlin/util/SinglePageApp.kt
new file mode 100644
index 00000000..87c1a899
--- /dev/null
+++ b/server/src/main/kotlin/util/SinglePageApp.kt
@@ -0,0 +1,81 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream.util
+
+import io.ktor.application.*
+import io.ktor.http.content.*
+import io.ktor.request.*
+import io.ktor.response.*
+import io.ktor.routing.*
+import io.ktor.util.*
+import java.io.File
+import java.util.concurrent.ConcurrentHashMap
+
+class SinglePageApp(
+ var defaultFile: String = "index.html",
+ var staticFilePath: String = "",
+ var ignoreBasePath: String = ""
+) {
+
+ companion object Feature : ApplicationFeature {
+ override val key = AttributeKey("SinglePageApp")
+
+ override fun install(pipeline: Application, configure: SinglePageApp.() -> Unit): SinglePageApp {
+ val configuration = SinglePageApp().apply(configure)
+ val defaultFile = File(configuration.staticFilePath, configuration.defaultFile)
+ val staticFileMap = ConcurrentHashMap()
+
+ pipeline.routing {
+ static("/") {
+ staticRootFolder = File(configuration.staticFilePath)
+ files("./")
+ default(configuration.defaultFile)
+ }
+
+ get("/*") {
+ call.respondRedirect("/")
+ }
+ }
+
+ pipeline.intercept(ApplicationCallPipeline.Features) {
+ if (!call.request.uri.startsWith(configuration.ignoreBasePath)) {
+ val path = call.request.uri.split("/")
+ if (path.last().contains(".")) {
+ try {
+ val file = staticFileMap.getOrPut(path.last()) {
+ val urlPathString = path.subList(1, path.lastIndex)
+ .fold(configuration.staticFilePath) { out, part -> "$out/$part" }
+ File(urlPathString, path.last())
+ .apply { check(exists()) }
+ }
+ call.respondFile(file)
+ return@intercept finish()
+ } catch (e: IllegalStateException) {
+ // No local resource, fall to other handlers
+ }
+ } else {
+ call.respondFile(defaultFile)
+ return@intercept finish()
+ }
+ }
+ }
+
+ return configuration
+ }
+ }
+}
diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf
new file mode 100644
index 00000000..94cbea93
--- /dev/null
+++ b/server/src/main/resources/application.conf
@@ -0,0 +1,31 @@
+ktor {
+ deployment {
+ port = 8888
+ port = ${?PORT}
+ watch = [ "server/build" ]
+ development = true
+ }
+ application {
+ modules = [
+ anystream.modules.StatusPageModuleKt.module
+ anystream.ApplicationKt.module
+ ]
+ }
+}
+
+app {
+ frontEndPath = "/app/client-web"
+ ffmpegPath = "/usr/bin"
+ transcodePath = "/tmp"
+ mongoUrl = "mongodb://root:password@localhost"
+ tmdbApiKey = "c1e9e8ade306dd9cbc5e17b05ed4badd"
+ qbittorrentUrl = "http://localhost:9090"
+ qbittorrentUser = "admin"
+ qbittorrentPassword = "adminadmin"
+}
+
+media {
+ rootPath = "/"
+ movieRootPaths = []
+ tvRootPaths = []
+}
\ No newline at end of file
diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml
new file mode 100644
index 00000000..d111b8dd
--- /dev/null
+++ b/server/src/main/resources/logback.xml
@@ -0,0 +1,15 @@
+
+
+
+ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/src/test/kotlin/ApplicationTest.kt b/server/src/test/kotlin/ApplicationTest.kt
new file mode 100644
index 00000000..f2255727
--- /dev/null
+++ b/server/src/test/kotlin/ApplicationTest.kt
@@ -0,0 +1,36 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package anystream
+
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.testing.handleRequest
+import io.ktor.server.testing.withTestApplication
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ApplicationTest {
+ @Test
+ fun testRoot() {
+ withTestApplication({ module(testing = true) }) {
+ handleRequest(HttpMethod.Get, "/").apply {
+ assertEquals(HttpStatusCode.OK, response.status())
+ }
+ }
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 00000000..2823450b
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,21 @@
+rootProject.name = "anystream"
+
+include(":server")
+include(":api-client")
+include(":data-models")
+include(":client")
+include(":client-web")
+include(":client-android")
+include(":preferences")
+include(":ktor-permissions")
+include(":torrent-search")
+
+enableFeaturePreview("VERSION_CATALOGS")
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ }
+}
diff --git a/torrent-search/build.gradle.kts b/torrent-search/build.gradle.kts
new file mode 100644
index 00000000..8b7d3369
--- /dev/null
+++ b/torrent-search/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ kotlin("jvm")
+ kotlin("plugin.serialization")
+}
+
+
+dependencies {
+ implementation(kotlin("stdlib"))
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.json)
+ implementation(libs.ktor.client.serialization)
+}
diff --git a/torrent-search/src/main/kotlin/Category.kt b/torrent-search/src/main/kotlin/Category.kt
new file mode 100644
index 00000000..fc59a065
--- /dev/null
+++ b/torrent-search/src/main/kotlin/Category.kt
@@ -0,0 +1,32 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch
+
+enum class Category {
+ ALL,
+ AUDIO,
+ VIDEO,
+ OTHER,
+ MOVIES,
+ XXX,
+ GAMES,
+ TV,
+ MUSIC,
+ APPS,
+ BOOKS
+}
diff --git a/torrent-search/src/main/kotlin/TorrentDescription.kt b/torrent-search/src/main/kotlin/TorrentDescription.kt
new file mode 100644
index 00000000..35c4b873
--- /dev/null
+++ b/torrent-search/src/main/kotlin/TorrentDescription.kt
@@ -0,0 +1,36 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+
+@Serializable
+data class TorrentDescription(
+ val provider: String,
+ val magnetUrl: String,
+ val title: String,
+ val size: Long,
+ val seeds: Int,
+ val peers: Int
+) {
+ @Transient
+ val hash: String = magnetUrl
+ .substringAfter("xt=urn:btih:")
+ .substringBefore("&")
+}
diff --git a/torrent-search/src/main/kotlin/TorrentProvider.kt b/torrent-search/src/main/kotlin/TorrentProvider.kt
new file mode 100644
index 00000000..7eed6266
--- /dev/null
+++ b/torrent-search/src/main/kotlin/TorrentProvider.kt
@@ -0,0 +1,53 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch
+
+
+interface TorrentProvider {
+
+ /** The Provider's name. */
+ val name: String
+
+ /** The Provider's base url. (ex. `https://provider.link`) */
+ val baseUrl: String
+
+ /** The Provider's path to acquire a token. */
+ val tokenPath: String
+
+ /** The Provider's path to search search data. */
+ val searchPath: String
+
+ /** Maps a url safe string of provider categories to a [Category]. */
+ val categories: Map
+
+ /** The result limit for search requests. */
+ val resultsPerPage: Int get() = 100
+
+ /** True if the provider is enabled. */
+ val isEnabled: Boolean
+
+ /**
+ * Execute a search for the given [query] in [category], returning
+ * [TorrentDescription]s for each of the Provider's entries.
+ */
+ suspend fun search(query: String, category: Category, limit: Int): List
+
+ fun enable(username: String? = null, password: String? = null, cookies: List = emptyList())
+
+ fun disable()
+}
diff --git a/torrent-search/src/main/kotlin/TorrentProviderCache.kt b/torrent-search/src/main/kotlin/TorrentProviderCache.kt
new file mode 100644
index 00000000..61746e5f
--- /dev/null
+++ b/torrent-search/src/main/kotlin/TorrentProviderCache.kt
@@ -0,0 +1,38 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch
+
+interface TorrentProviderCache {
+
+ fun saveToken(provider: TorrentProvider, token: String)
+
+ fun loadToken(provider: TorrentProvider): String?
+
+ fun saveResults(
+ provider: TorrentProvider,
+ query: String,
+ category: Category,
+ results: List
+ )
+
+ fun loadResults(
+ provider: TorrentProvider,
+ query: String,
+ category: Category
+ ): List?
+}
diff --git a/torrent-search/src/main/kotlin/TorrentSearch.kt b/torrent-search/src/main/kotlin/TorrentSearch.kt
new file mode 100644
index 00000000..46555a5e
--- /dev/null
+++ b/torrent-search/src/main/kotlin/TorrentSearch.kt
@@ -0,0 +1,129 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch
+
+import drewcarlson.torrentsearch.providers.LibreProvider
+import drewcarlson.torrentsearch.providers.PirateBayProvider
+import drewcarlson.torrentsearch.providers.RarbgProvider
+import io.ktor.client.HttpClient
+import io.ktor.client.features.*
+import io.ktor.client.features.cookies.AcceptAllCookiesStorage
+import io.ktor.client.features.cookies.HttpCookies
+import io.ktor.client.features.json.JsonFeature
+import io.ktor.client.features.json.serializer.KotlinxSerializer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.reduce
+import kotlinx.coroutines.flow.take
+
+class TorrentSearch(
+ private val providerCache: TorrentProviderCache? = null,
+ httpClient: HttpClient = HttpClient(),
+ vararg providers: TorrentProvider
+) {
+
+ private val http = httpClient.config {
+ install(JsonFeature) {
+ serializer = KotlinxSerializer()
+ }
+
+ install(HttpCookies) {
+ storage = AcceptAllCookiesStorage()
+ }
+ }
+
+ private val providers = listOf(
+ RarbgProvider(http, providerCache),
+ PirateBayProvider(http),
+ LibreProvider()
+ ) + providers
+
+ /**
+ * Search all enabled providers with [query] and [category].
+ *
+ * All results are merged into a single list. [limit] is used
+ * when possible to limit the result count from each provider.
+ */
+ suspend fun search(query: String, category: Category, limit: Int): List {
+ return searchFlow(query, category, limit).reduce { acc, next -> acc + next }
+ }
+
+ /**
+ * Search all enabled providers with [query] and [category],
+ * emitting each set of results as the providers respond.
+ *
+ * [limit] is used when possible to limit the result count
+ * from each provider.
+ */
+ fun searchFlow(query: String, category: Category, limit: Int): Flow> {
+ return providers
+ .filter(TorrentProvider::isEnabled)
+ .map { provider ->
+ println("Searching '${provider.name}' for '$query'")
+ flow {
+ try {
+ emit(provider.search(query, category, limit))
+ } catch (e: ClientRequestException) {
+ println("Search failed for '${provider.name}'")
+ e.printStackTrace()
+ }
+ }.onEach { results ->
+ if (results.isNotEmpty()) {
+ providerCache?.saveResults(provider, query, category, results)
+ }
+ }.onStart {
+ val cacheResult = providerCache?.loadResults(provider, query, category)
+ if (cacheResult != null) {
+ emit(cacheResult)
+ }
+ }.take(1)
+ }
+ .merge()
+ .flowOn(Dispatchers.Default)
+ }
+
+ /**
+ * Returns a list of enabled providers.
+ */
+ fun enabledProviders() = providers.filter(TorrentProvider::isEnabled).toList()
+
+ /**
+ * Returns a list of available providers.
+ */
+ fun availableProviders() = providers.toList()
+
+ /**
+ * Enable the provider [name] with the included credentials and [cookies].
+ */
+ fun enableProvider(name: String, username: String?, password: String?, cookies: List) {
+ providers.singleOrNull { it.name == name }
+ ?.enable(username, password, cookies)
+ }
+
+ /**
+ * Disable the provider [name].
+ */
+ fun disableProvider(name: String) {
+ providers.singleOrNull { it.name == name }?.disable()
+ }
+}
diff --git a/torrent-search/src/main/kotlin/providers/BaseTorrentProvider.kt b/torrent-search/src/main/kotlin/providers/BaseTorrentProvider.kt
new file mode 100644
index 00000000..5202238d
--- /dev/null
+++ b/torrent-search/src/main/kotlin/providers/BaseTorrentProvider.kt
@@ -0,0 +1,48 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch.providers
+
+import drewcarlson.torrentsearch.TorrentProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlin.coroutines.CoroutineContext
+
+abstract class BaseTorrentProvider(
+ enabledByDefault: Boolean = true
+) : TorrentProvider, CoroutineScope {
+
+ private var enabled = enabledByDefault
+
+ override val coroutineContext: CoroutineContext =
+ Dispatchers.Default + SupervisorJob()
+
+ final override val isEnabled: Boolean = enabled
+
+ override fun enable(
+ username: String?,
+ password: String?,
+ cookies: List
+ ) {
+ enabled = true
+ }
+
+ override fun disable() {
+ enabled = false
+ }
+}
diff --git a/torrent-search/src/main/kotlin/providers/LibreProvider.kt b/torrent-search/src/main/kotlin/providers/LibreProvider.kt
new file mode 100644
index 00000000..5bda061a
--- /dev/null
+++ b/torrent-search/src/main/kotlin/providers/LibreProvider.kt
@@ -0,0 +1,75 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch.providers
+
+import drewcarlson.torrentsearch.Category
+import drewcarlson.torrentsearch.TorrentDescription
+
+class LibreProvider : BaseTorrentProvider() {
+ override val name: String = "libre"
+ override val baseUrl: String = ""
+ override val tokenPath: String = ""
+ override val searchPath: String = ""
+ override val categories: Map = emptyMap()
+
+ override suspend fun search(query: String, category: Category, limit: Int): List {
+ return when (query.toLowerCase()) {
+ "sintel" -> listOf(
+ TorrentDescription(
+ provider = name,
+ magnetUrl = "magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel.torrent",
+ title = "Sintel",
+ size = 0L,
+ seeds = 0,
+ peers = 0
+ )
+ )
+ "big buck bunny" -> listOf(
+ TorrentDescription(
+ provider = name,
+ magnetUrl = "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fbig-buck-bunny.torrent",
+ title = "Big Buck Bunny",
+ size = 0L,
+ seeds = 0,
+ peers = 0
+ )
+ )
+ "cosmos laundromat" -> listOf(
+ TorrentDescription(
+ provider = name,
+ magnetUrl = "magnet:?xt=urn:btih:c9e15763f722f23e98a29decdfae341b98d53056&dn=Cosmos+Laundromat&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fcosmos-laundromat.torrent",
+ title = "Cosmos Laundromat",
+ size = 0L,
+ seeds = 0,
+ peers = 0
+ )
+ )
+ "tears of steal" -> listOf(
+ TorrentDescription(
+ provider = name,
+ magnetUrl = "Tears of Steal",
+ title = "magnet:?xt=urn:btih:209c8226b299b308beaf2b9cd3fb49212dbd13ec&dn=Tears+of+Steel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Ftears-of-steel.torrent",
+ size = 0L,
+ seeds = 0,
+ peers = 0
+ )
+ )
+ else -> emptyList()
+ }
+ }
+}
diff --git a/torrent-search/src/main/kotlin/providers/PirateBayProvider.kt b/torrent-search/src/main/kotlin/providers/PirateBayProvider.kt
new file mode 100644
index 00000000..713c8bf8
--- /dev/null
+++ b/torrent-search/src/main/kotlin/providers/PirateBayProvider.kt
@@ -0,0 +1,114 @@
+/**
+ * AnyStream
+ * Copyright (C) 2021 Drew Carlson
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package drewcarlson.torrentsearch.providers
+
+import drewcarlson.torrentsearch.Category
+import drewcarlson.torrentsearch.TorrentDescription
+import drewcarlson.torrentsearch.TorrentProviderCache
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import kotlinx.serialization.json.*
+
+internal class PirateBayProvider(
+ private val httpClient: HttpClient
+) : BaseTorrentProvider() {
+
+ override val name: String = "ThePirateBay"
+ override val baseUrl: String = "https://apibay.org"
+ override val tokenPath: String = ""
+ override val searchPath: String = "/q.php?q={query}&cat={category}"
+
+ override val categories = mapOf(
+ Category.ALL to "",
+ Category.AUDIO to "100",
+ Category.MUSIC to "101",
+ Category.VIDEO to "200",
+ Category.MOVIES to "201",
+ Category.TV to "205",
+ Category.APPS to "300",
+ Category.GAMES to "400",
+ Category.XXX to "500",
+ Category.OTHER to "600",
+ )
+
+ private val trackers = listOf(
+ "udp://tracker.coppersurfer.tk:6969/announce",
+ "udp://9.rarbg.to:2920/announce",
+ "udp://tracker.opentrackr.org:1337",
+ "udp://tracker.internetwarriors.net:1337/announce",
+ "udp://tracker.leechers-paradise.org:6969/announce",
+ "udp://tracker.pirateparty.gr:6969/announce",
+ "udp://tracker.cyberia.is:6969/announce"
+ ).map { it.encodeURLQueryComponent() }
+
+ override suspend fun search(query: String, category: Category, limit: Int): List {
+ val categoryString = categories[category]
+
+ if (query.isBlank() || categoryString.isNullOrBlank()) {
+ return emptyList()
+ }
+ val response = httpClient.get {
+ url {
+ takeFrom(baseUrl)
+ takeFrom(
+ searchPath
+ .replace("{query}", query.encodeURLQueryComponent())
+ .replace("{category}", categoryString)
+ )
+ }
+ }
+
+ return if (response.status == HttpStatusCode.OK) {
+ val torrents = response.call.receive()
+ val noResults = torrents.singleOrNull()
+ ?.jsonObject
+ ?.get("info_hash")
+ ?.jsonPrimitive
+ ?.content
+ ?.all { it == '0' } ?: false
+ if (noResults) {
+ emptyList()
+ } else {
+ torrents.map { element ->
+ val torrentName = element.jsonObject["name"]?.jsonPrimitive?.content ?: "