diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a235cd82..7e572ff5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,6 +15,7 @@ plugins { alias(libs.plugins.protobuf) alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.aboutLibraries) + alias(libs.plugins.openapi.generator) } val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false @@ -145,6 +146,12 @@ android { isUniversalApk = true } } + + sourceSets { + getByName("main") { + kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin") + } + } } protobuf { @@ -176,6 +183,33 @@ aboutLibraries { } } +openApiGenerate { + generatorName.set("kotlin") + inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml") + templateDir.set("$projectDir/src/main/seerr/templates") + outputDir.set("$buildDir/generated/seerr_api") + apiPackage.set("com.github.damontecres.wholphin.api.seerr") + modelPackage.set("com.github.damontecres.wholphin.api.seerr.model") + groupId.set("com.github.damontecres.wholphin.api.seerr") + id.set("seerr-api") + packageName.set("com.github.damontecres.wholphin.api.seerr") + additionalProperties.apply { + put("serializationLibrary", "kotlinx_serialization") + put("sortModelPropertiesByRequiredFlag", true) + put("sortParamsByRequiredFlag", true) + put("useCoroutines", true) + put("enumPropertyNaming", "UPPERCASE") + put("modelMutable", false) + + // Note: this is only for downloading files, so it's not necessary to enable + put("supportAndroidApiLevel25AndBelow", false) + } +} + +tasks.named("preBuild") { + dependsOn.add(tasks.named("openApiGenerate")) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -246,6 +280,8 @@ dependencies { implementation(libs.acra.limiter) compileOnly(libs.auto.service.annotations) ksp(libs.auto.service.ksp) + implementation(platform(libs.okhttp.bom)) + implementation(libs.okhttp) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json new file mode 100644 index 00000000..9c365430 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json @@ -0,0 +1,549 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "dea51adcc724179afa0174d775f97480", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dea51adcc724179afa0174d775f97480')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 0d067f2a..fb3e88ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig @@ -105,6 +106,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var refreshRateService: RefreshRateService + @Inject + lateinit var userSwitchListener: UserSwitchListener + @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService diff --git a/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt new file mode 100644 index 00000000..4a76a00c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt @@ -0,0 +1,87 @@ +package com.github.damontecres.wholphin.api.seerr + +import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import okhttp3.Call +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import timber.log.Timber + +class SeerrApiClient( + val baseUrl: String, + private val apiKey: String?, + okHttpClient: OkHttpClient, +) { + private val cookieJar = SeerrCookieJar() + + private val client = + okHttpClient + .newBuilder() + .cookieJar(cookieJar) + .addInterceptor { + Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}") + it.proceed( + it + .request() + .newBuilder() + .apply { + if (apiKey.isNotNullOrBlank()) header("X-Api-Key", apiKey) + }.build(), + ) + }.build() + + val hasValidCredentials: Boolean + get() = + apiKey.isNotNullOrBlank() || + cookieJar.hasValidCredentials(baseUrl) + + private fun create(initializer: (String, Call.Factory) -> T): Lazy = + lazy { + initializer.invoke(baseUrl, client) + } + + val authApi by create(::AuthApi) + val blacklistApi by create(::BlacklistApi) + val collectionApi by create(::CollectionApi) + val issueApi by create(::IssueApi) + val mediaApi by create(::MediaApi) + val moviesApi by create(::MoviesApi) + val otherApi by create(::OtherApi) + val overrideruleApi by create(::OverrideruleApi) + val personApi by create(::PersonApi) + val publicApi by create(::PublicApi) + val requestApi by create(::RequestApi) + val searchApi by create(::SearchApi) + val serviceApi by create(::ServiceApi) + val settingsApi by create(::SettingsApi) + val tmdbApi by create(::TmdbApi) + val tvApi by create(::TvApi) + val usersApi by create(::UsersApi) + val watchlistApi by create(::WatchlistApi) +} + +private class SeerrCookieJar : CookieJar { + private val cookies = mutableMapOf>() + + override fun saveFromResponse( + url: HttpUrl, + cookies: List, + ) { + cookies + .filter { it.name == "connect.sid" } + .groupBy { it.domain } + .forEach { (domain, cookies) -> + this.cookies[domain] = cookies + } + } + + override fun loadForRequest(url: HttpUrl): List = this.cookies[url.host].orEmpty() + + fun hasValidCredentials(baseUrl: String): Boolean = + baseUrl.toHttpUrlOrNull()?.host?.let { domain -> + cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() } + } == true +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index fe6e7620..19e33616 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy @@ -32,8 +34,10 @@ import java.util.UUID LibraryDisplayInfo::class, PlaybackLanguageChoice::class, ItemTrackModification::class, + SeerrServer::class, + SeerrUser::class, ], - version = 12, + version = 20, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -45,6 +49,7 @@ import java.util.UUID AutoMigration(9, 10), AutoMigration(10, 11), AutoMigration(11, 12), + AutoMigration(12, 20), ], ) @TypeConverters(Converters::class) @@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao + + abstract fun seerrServerDao(): SeerrServerDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt index ba070116..2312c62c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -4,11 +4,13 @@ import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -24,6 +26,7 @@ class NavDrawerItemRepository private val api: ApiClient, private val serverRepository: ServerRepository, private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, ) { suspend fun getNavDrawerItems(): List { val user = serverRepository.currentUser.value @@ -46,7 +49,13 @@ class NavDrawerItemRepository setOf() } - val builtins = listOf(NavDrawerItem.Favorites) + val builtins = + if (seerrServerRepository.active.first()) { + listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + } else { + listOf(NavDrawerItem.Favorites) + } + val libraries = userViews .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt new file mode 100644 index 00000000..59f5d9ea --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -0,0 +1,65 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrServerUsers +import com.github.damontecres.wholphin.data.model.SeerrUser + +@Dao +interface SeerrServerDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun addServer(server: SeerrServer): Long + + @Update + suspend fun updateServer(server: SeerrServer): Int + + @Transaction + suspend fun addOrUpdateServer(server: SeerrServer) { + val result = addServer(server) + if (result == -1L) { + updateServer(server) + } + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addUser(user: SeerrUser): Long + + suspend fun updateUser(user: SeerrUser) = addUser(user) + + @Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") + suspend fun getUser( + serverId: Int, + jellyfinUserRowId: Int, + ): SeerrUser? + + @Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId") + suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List + + @Query("DELETE FROM seerr_servers WHERE id = :serverId") + suspend fun deleteServer(serverId: Int) + + @Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") + suspend fun deleteUser( + serverId: Int, + jellyfinUserRowId: Int, + ) + + suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) + + @Transaction + @Query("SELECT * FROM seerr_servers") + suspend fun getServers(): List + + @Transaction + @Query("SELECT * FROM seerr_servers WHERE id = :serverId") + suspend fun getServer(serverId: Int): SeerrServerUsers? + + @Transaction + @Query("SELECT * FROM seerr_servers WHERE url = :url") + suspend fun getServer(url: String): SeerrServerUsers? +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 599b7638..b5ed3259 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -23,7 +23,9 @@ data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, ) : CardGridItem { - override val id get() = data.id + val id get() = data.id + + override val gridId get() = id.toString() override val playable: Boolean get() = type.playable @@ -88,23 +90,21 @@ data class BaseItem( Destination.SeriesOverview( data.seriesId!!, BaseItemKind.SERIES, - this, SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), ) - } ?: Destination.MediaItem(id, type, this) + } ?: Destination.MediaItem(this) } BaseItemKind.SEASON -> { Destination.SeriesOverview( data.seriesId!!, BaseItemKind.SERIES, - this, SeasonEpisodeIds(id, indexNumber, null, null), ) } else -> { - Destination.MediaItem(id, type, this) + Destination.MediaItem(this) } } return result diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt new file mode 100644 index 00000000..fc307b34 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -0,0 +1,226 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.api.seerr.model.CreditCast +import com.github.damontecres.wholphin.api.seerr.model.CreditCrew +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.services.SeerrSearchResult +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toLocalDate +import com.github.damontecres.wholphin.util.LocalDateSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.time.LocalDate +import java.util.UUID + +@Serializable +enum class SeerrItemType( + val baseItemKind: BaseItemKind?, +) { + @SerialName("movie") + MOVIE(BaseItemKind.MOVIE), + + @SerialName("tv") + TV(BaseItemKind.SERIES), + + @SerialName("person") + PERSON(BaseItemKind.PERSON), + + @SerialName("unknown") + UNKNOWN(null), + ; + + companion object { + fun fromString( + str: String?, + fallback: SeerrItemType = UNKNOWN, + ) = when (str) { + "movie" -> MOVIE + "tv" -> TV + "person" -> PERSON + else -> fallback + } + } +} + +@Serializable +enum class SeerrAvailability( + val status: Int, +) { + UNKNOWN(1), + PENDING(2), + PROCESSING(3), + PARTIALLY_AVAILABLE(4), + AVAILABLE(5), + DELETED(6), + ; + + companion object { + fun from(status: Int?) = entries.firstOrNull { it.status == status } + } +} + +/** + * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. + */ +@Serializable +data class DiscoverItem( + val id: Int, + val type: SeerrItemType, + val title: String?, + val subtitle: String?, + val overview: String?, + val availability: SeerrAvailability, + @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, + val posterPath: String?, + val backdropPath: String?, + val jellyfinItemId: UUID?, +) : CardGridItem { + override val gridId: String get() = id.toString() + override val playable: Boolean = false + override val sortName: String get() = title ?: "" + + val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces$it" } + val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" } + + val destination: Destination + get() { + val jfType = + when (type) { + SeerrItemType.MOVIE -> BaseItemKind.MOVIE + SeerrItemType.TV -> BaseItemKind.SERIES + SeerrItemType.PERSON -> BaseItemKind.PERSON + SeerrItemType.UNKNOWN -> null + } + return if (jellyfinItemId != null && jfType != null) { + Destination.MediaItem( + itemId = jellyfinItemId, + type = jfType, + ) + } else { + Destination.DiscoveredItem(this) + } + } + + constructor(movie: MovieResult) : this( + id = movie.id, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterPath = movie.posterPath, + backdropPath = movie.backdropPath, + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(movie: MovieDetails) : this( + id = movie.id ?: -1, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterPath = movie.posterPath, + backdropPath = movie.backdropPath, + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(tv: TvResult) : this( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(tv: TvDetails) : this( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(search: SeerrSearchResult) : this( + id = search.id, + type = SeerrItemType.fromString(search.mediaType), + title = search.title ?: search.name, + subtitle = null, + overview = search.overview, + availability = + SeerrAvailability.from(search.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), + posterPath = search.posterPath, + backdropPath = search.backdropPath, + jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(credit: CreditCast) : this( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = null, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterPath = credit.posterPath ?: credit.profilePath, + backdropPath = credit.backdropPath, + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(credit: CreditCrew) : this( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = null, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterPath = credit.posterPath ?: credit.profilePath, + backdropPath = credit.backdropPath, + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) +} + +data class DiscoverRating( + val criticRating: Int?, + val audienceRating: Float?, +) { + constructor(rating: MovieMovieIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = rating.audienceScore?.div(10f), + ) + constructor(rating: TvTvIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = null, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt new file mode 100644 index 00000000..1e9f963d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.services.SeerrUserConfig + +enum class SeerrPermission( + private val flag: Int, +) { + // Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts + NONE(0), + ADMIN(2), + MANAGE_SETTINGS(4), + MANAGE_USERS(8), + MANAGE_REQUESTS(16), + REQUEST(32), + VOTE(64), + AUTO_APPROVE(128), + AUTO_APPROVE_MOVIE(256), + AUTO_APPROVE_TV(512), + REQUEST_4K(1024), + REQUEST_4K_MOVIE(2048), + REQUEST_4K_TV(4096), + REQUEST_ADVANCED(8192), + REQUEST_VIEW(16384), + AUTO_APPROVE_4K(32768), + AUTO_APPROVE_4K_MOVIE(65536), + AUTO_APPROVE_4K_TV(131072), + REQUEST_MOVIE(262144), + REQUEST_TV(524288), + MANAGE_ISSUES(1048576), + VIEW_ISSUES(2097152), + CREATE_ISSUES(4194304), + AUTO_REQUEST(8388608), + AUTO_REQUEST_MOVIE(16777216), + AUTO_REQUEST_TV(33554432), + RECENT_VIEW(67108864), + WATCHLIST_VIEW(134217728), + MANAGE_BLACKLIST(268435456), + VIEW_BLACKLIST(1073741824), + ; + + internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag +} + +/** + * Check whether the user has the given permissions (or is an admin) + */ +fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { + return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt new file mode 100644 index 00000000..e29305d2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt @@ -0,0 +1,72 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.serializer.UUIDSerializer + +@Entity( + tableName = "seerr_servers", + indices = [Index("url", unique = true)], +) +@Serializable +data class SeerrServer( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val url: String, + val name: String? = null, + val version: String? = null, +) + +@Entity( + tableName = "seerr_users", + foreignKeys = [ + ForeignKey( + entity = SeerrServer::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("serverId"), + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("jellyfinUserRowId"), + onDelete = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["jellyfinUserRowId", "serverId"], +) +data class SeerrUser( + val jellyfinUserRowId: Int, + val serverId: Int, + val authMethod: SeerrAuthMethod, + val username: String?, + val password: String?, + val credential: String?, +) { + override fun toString(): String = + "SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})" +} + +enum class SeerrAuthMethod { + LOCAL, + JELLYFIN, + API_KEY, +} + +data class SeerrServerUsers( + @Embedded val server: SeerrServer, + @Relation( + parentColumn = "id", + entityColumn = "serverId", + ) + val users: List, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 5ed8e6dc..c15b7bc2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -871,6 +871,13 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val SeerrIntegration = + AppClickablePreference( + title = R.string.seerr_integration, + getter = { }, + setter = { prefs, _ -> prefs }, + ) } } @@ -932,6 +939,7 @@ val basicPreferences = title = R.string.more, preferences = listOf( + AppPreference.SeerrIntegration, AppPreference.AdvancedSettings, ), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index cdbb7a2a..a64fc9d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -15,6 +15,7 @@ import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.request.bitmapConfig import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle import dagger.hilt.android.qualifiers.ApplicationContext @@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -47,27 +47,38 @@ class BackdropService suspend fun submit(item: BaseItem) = withContext(Dispatchers.IO) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { - _backdropFlow.update { - it.copy( - itemId = item.id, - imageUrl = null, - ) - } - extractColors(item) - } + val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + submit(item.id.toString(), imageUrl) } + suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl) + + suspend fun submit( + itemId: String, + imageUrl: String?, + ) = withContext(Dispatchers.IO) { + if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { + _backdropFlow.update { + it.copy( + itemId = itemId, + imageUrl = null, + ) + } + extractColors(itemId, imageUrl) + } + } + suspend fun clearBackdrop() { _backdropFlow.update { BackdropResult.NONE } } - private suspend fun extractColors(item: BaseItem) { + private suspend fun extractColors( + itemId: String, + imageUrl: String?, + ) { delay(500) - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) val backdropStyle = preferences.data .firstOrNull() @@ -83,9 +94,9 @@ class BackdropService ExtractedColors.DEFAULT } _backdropFlow.update { - if (it.itemId == item.id) { + if (it.itemId == itemId) { BackdropResult( - itemId = item.id, + itemId = itemId, imageUrl = imageUrl, primaryColor = primaryColor, secondaryColor = secondaryColor, @@ -208,7 +219,7 @@ class BackdropService } data class BackdropResult( - val itemId: UUID?, + val itemId: String?, val imageUrl: String?, val primaryColor: Color = Color.Unspecified, val secondaryColor: Color = Color.Unspecified, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt new file mode 100644 index 00000000..c68cd3d2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -0,0 +1,29 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import okhttp3.OkHttpClient + +/** + * Wrapper for [SeerrApiClient]. In most cases, you should use [SeerrService] instead. + */ +class SeerrApi( + private val okHttpClient: OkHttpClient, +) { + var api: SeerrApiClient = + SeerrApiClient( + baseUrl = "", + apiKey = null, + okHttpClient = okHttpClient, + ) + private set + + val active: Boolean get() = api.baseUrl.isNotNullOrBlank() + + fun update( + baseUrl: String, + apiKey: String?, + ) { + api = SeerrApiClient(baseUrl, apiKey, okHttpClient) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt new file mode 100644 index 00000000..d846b6ee --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -0,0 +1,252 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.asFlow +import androidx.lifecycle.lifecycleScope +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest +import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest +import com.github.damontecres.wholphin.api.seerr.model.User +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import okhttp3.OkHttpClient +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. + */ +@Singleton +class SeerrServerRepository + @Inject + constructor( + private val seerrApi: SeerrApi, + private val seerrServerDao: SeerrServerDao, + private val serverRepository: ServerRepository, + @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, + ) { + private val _current = MutableStateFlow(null) + val current: StateFlow = _current + val currentServer: Flow = current.map { it?.server } + val currentUser: Flow = current.map { it?.user } + + /** + * Whether Seerr integration is currently active of not + */ + val active: Flow = current.map { it != null && seerrApi.active } + + fun clear() { + _current.update { null } + seerrApi.update("", null) + } + + suspend fun set( + server: SeerrServer, + user: SeerrUser, + userConfig: SeerrUserConfig, + ) { + _current.update { + CurrentSeerr(server, user, userConfig) + } + } + + suspend fun addAndChangeServer( + url: String, + apiKey: String, + ) { + var server = seerrServerDao.getServer(url) + if (server == null) { + seerrServerDao.addServer(SeerrServer(url = url)) + server = seerrServerDao.getServer(url) + } + server?.server?.let { server -> + serverRepository.currentUser.value?.let { jellyfinUser -> + // TODO test api key + val user = + SeerrUser( + jellyfinUserRowId = jellyfinUser.rowId, + serverId = server.id, + authMethod = SeerrAuthMethod.API_KEY, + username = null, + password = null, + credential = apiKey, + ) + seerrServerDao.addUser(user) + + seerrApi.update(server.url, apiKey) + val userConfig = seerrApi.api.usersApi.authMeGet() + set(server, user, userConfig) + } + } + } + + suspend fun addAndChangeServer( + url: String, + authMethod: SeerrAuthMethod, + username: String, + password: String, + ) { + var server = seerrServerDao.getServer(url) + if (server == null) { + seerrServerDao.addServer(SeerrServer(url = url)) + server = seerrServerDao.getServer(url) + } + server?.server?.let { server -> + serverRepository.currentUser.value?.let { jellyfinUser -> + // TODO Need to update server early so that cookies are saved + seerrApi.update(server.url, null) + val userConfig = login(seerrApi.api, authMethod, username, password) + + val user = + SeerrUser( + jellyfinUserRowId = jellyfinUser.rowId, + serverId = server.id, + authMethod = authMethod, + username = username, + password = password, + credential = null, + ) + seerrServerDao.addUser(user) + set(server, user, userConfig) + } + } + } + + suspend fun testConnection( + authMethod: SeerrAuthMethod, + url: String, + username: String?, + passwordOrApiKey: String, + ): LoadingState { + val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) + try { + login(api, authMethod, username, passwordOrApiKey) + return LoadingState.Success + } catch (ex: Exception) { + Timber.w(ex, "Error testing seerr connection") + return LoadingState.Error(ex) + } + } + + suspend fun removeServer() { + val current = _current.value ?: return + seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + clear() + } + } + +/** + * A [SeerrUser] config + */ +typealias SeerrUserConfig = User + +data class CurrentSeerr( + val server: SeerrServer, + val user: SeerrUser, + val config: SeerrUserConfig, +) + +private suspend fun login( + client: SeerrApiClient, + authMethod: SeerrAuthMethod, + username: String?, + password: String?, +): User = + when (authMethod) { + SeerrAuthMethod.LOCAL -> { + client.authApi.authLocalPost( + AuthLocalPostRequest( + email = username ?: "", + password = password ?: "", + ), + ) + } + + SeerrAuthMethod.JELLYFIN -> { + client.authApi.authJellyfinPost( + AuthJellyfinPostRequest( + username = username ?: "", + password = password ?: "", + ), + ) + } + + SeerrAuthMethod.API_KEY -> { + client.usersApi.authMeGet() + } + } + +/** + * Listens for JF user switching in the app to also switch the Seerr user/server + */ +@ActivityScoped +class UserSwitchListener + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val seerrServerRepository: SeerrServerRepository, + private val seerrServerDao: SeerrServerDao, + private val seerrApi: SeerrApi, + ) { + init { + context as AppCompatActivity + context.lifecycleScope.launchIO { + serverRepository.currentUser.asFlow().collect { user -> + Timber.d("New user") + seerrServerRepository.clear() + if (user != null) { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .firstOrNull() + ?.let { seerrUser -> + val server = seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + try { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } catch (ex: Exception) { + Timber.w(ex, "Error logging into %s", server.url) + seerrServerRepository.clear() + return@let + } + } else { + try { + seerrApi.api.usersApi.authMeGet() + } catch (ex: Exception) { + Timber.w(ex, "Error logging into %s", server.url) + seerrServerRepository.clear() + return@let + } + } + seerrServerRepository.set(server, seerrUser, userConfig) + } + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt new file mode 100644 index 00000000..e38ba41b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -0,0 +1,128 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.model.api.BaseItemKind +import javax.inject.Inject +import javax.inject.Singleton + +typealias SeerrSearchResult = SearchGet200ResponseResultsInner + +/** + * Main access for the current Seerr server (if any) + * + * Exposes a [SeerrApiClient] for queries + */ +@Singleton +class SeerrService + @Inject + constructor( + private val seerApi: SeerrApi, + private val seerrServerRepository: SeerrServerRepository, + ) { + val api: SeerrApiClient get() = seerApi.api + + val active get() = seerrServerRepository.active + + suspend fun search( + query: String, + page: Int = 1, + ): List = + api.searchApi + .searchGet(query = query, page = page) + .results + .orEmpty() + + suspend fun discoverTv(page: Int = 1): List = + api.searchApi + .discoverTvGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun discoverMovies(page: Int = 1): List = + api.searchApi + .discoverMoviesGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun trending(page: Int = 1): List = + api.searchApi + .discoverTrendingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun upcomingMovies(page: Int = 1): List = + api.searchApi + .discoverMoviesUpcomingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun upcomingTv(page: Int = 1): List = + api.searchApi + .discoverTvUpcomingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + /** + * Get [DiscoverItem]s similar to the JF items such as movies, series, or people + * + * If Seerr integration is not active, this short circuits to return null + * + * @return the discovered items or null if no Seerr server configured + */ + suspend fun similar(item: BaseItem): List? = + if (active.first()) { + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { + when (item.type) { + BaseItemKind.MOVIE -> { + api.moviesApi + .movieMovieIdSimilarGet(movieId = it) + .results + ?.map(::DiscoverItem) + } + + BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { + api.tvApi + .tvTvIdSimilarGet(tvId = it) + .results + ?.map(::DiscoverItem) + } + + BaseItemKind.PERSON -> { + api.personApi + .personPersonIdCombinedCreditsGet(personId = it) + .let { credits -> + val cast = + credits.cast + ?.take(25) + ?.map(::DiscoverItem) + .orEmpty() + val crew = + credits.crew + ?.take(25) + ?.map(::DiscoverItem) + .orEmpty() + cast + crew + } + } + + else -> { + null + } + } + }.orEmpty() + } else { + null + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 856493f6..3f676cba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -88,7 +88,6 @@ class TrailerService navigateTo.invoke( Destination.Playback( itemId = trailer.baseItem.id, - item = trailer.baseItem, positionMs = 0L, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index c7ac435c..f4aa80dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.internal.headersContentLength import timber.log.Timber import java.io.File import java.io.InputStream @@ -211,7 +210,7 @@ class UpdateChecker if (it.isSuccessful && it.body != null) { Timber.v("Request successful for ${release.downloadUrl}") withContext(Dispatchers.Main) { - callback.contentLength(it.headersContentLength()) + callback.contentLength(it.body.contentLength()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index ad778590..e6ae832a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences +import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -174,4 +175,10 @@ object AppModule { @Singleton @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = SeerrApi(okHttpClient) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 30fb4422..ed3f761b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -61,6 +62,10 @@ object DatabaseModule { @Singleton fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index f6d0e467..2461b642 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -4,9 +4,11 @@ import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType +import timber.log.Timber import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Locale @@ -23,6 +25,16 @@ fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateT fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) +fun toLocalDate(date: String?): LocalDate? = + date?.let { + try { + LocalDate.parse(it) + } catch (_: DateTimeParseException) { + Timber.w("Could not parse date: %s", date) + null + } + } + /** * If the item has season & episode info, format as `S# E#` */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 989bb2f9..54f564f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -21,18 +21,23 @@ val LocalImageUrlService = /** * Colors not associated with the theme */ -sealed class AppColors private constructor() { - companion object { - val TransparentBlack25 = Color(0x40000000) - val TransparentBlack50 = Color(0x80000000) - val TransparentBlack75 = Color(0xBF000000) +object AppColors { + val TransparentBlack25 = Color(0x40000000) + val TransparentBlack50 = Color(0x80000000) + val TransparentBlack75 = Color(0xBF000000) - val DarkGreen = Color(0xFF114000) - val DarkRed = Color(0xFF400000) - val DarkCyan = Color(0xFF21556E) - val DarkPurple = Color(0xFF261370) + val DarkGreen = Color(0xFF114000) + val DarkRed = Color(0xFF400000) + val DarkCyan = Color(0xFF21556E) + val DarkPurple = Color(0xFF261370) - val GoldenYellow = Color(0xFFDAB440) + val GoldenYellow = Color(0xFFDAB440) + + object Discover { + val Blue = Color(37, 99, 235) + val Purple = Color(147, 51, 234) + val Green = Color(74, 222, 128) + val Yellow = Color(234, 179, 8) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt new file mode 100644 index 00000000..4e1d0e86 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -0,0 +1,294 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import kotlinx.coroutines.delay + +@Composable +@NonRestartableComposable +fun DiscoverItemCard( + item: DiscoverItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + showOverlay: Boolean, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + val width = Cards.height2x3 * AspectRatios.TALL + val height = Dp.Unspecified * (1f / AspectRatios.TALL) + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier.size(width, height), + ) { + Card( + modifier = + Modifier + .size(Dp.Unspecified, Cards.height2x3) + .aspectRatio(AspectRatios.TALL), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + ItemCardImage( + imageUrl = item?.posterUrl, + name = item?.title, + showOverlay = false, + favorite = false, + watched = false, + unwatchedCount = 0, + watchedPercent = null, + numberOfVersions = -1, + useFallbackText = false, + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .fillMaxSize(), + ) + when (item?.availability) { + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + PendingIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.PARTIALLY_AVAILABLE -> { + PartiallyAvailableIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.AVAILABLE, + -> { + AvailableIndicator(Modifier.align(Alignment.TopEnd)) + } + + else -> {} + } + if (showOverlay) { + val color = + remember(item?.type) { + when (item?.type) { + SeerrItemType.MOVIE -> AppColors.Discover.Blue + SeerrItemType.TV -> AppColors.Discover.Purple + SeerrItemType.PERSON -> AppColors.Discover.Green + SeerrItemType.UNKNOWN -> Color.Black + null -> Color.Black + }.copy(alpha = .8f) + } + val text = + remember(item?.type) { + when (item?.type) { + SeerrItemType.MOVIE -> R.plurals.movies + SeerrItemType.TV -> R.plurals.tv_shows + SeerrItemType.PERSON -> R.plurals.people + SeerrItemType.UNKNOWN -> null + null -> null + } + } + text?.let { + Text( + text = pluralStringResource(it, 1), + style = MaterialTheme.typography.bodySmall, + fontSize = 10.sp, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .background( + color = color, + shape = CircleShape, + ).padding(4.dp), + ) + } + } + } + } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = item?.title ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = item?.releaseDate?.year?.toString() ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} + +@Composable +fun PendingIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = AppColors.Discover.Yellow, + shape = CircleShape, + ).background( + color = Color.White.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_bell), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = AppColors.Discover.Yellow, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +fun AvailableIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Green.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_check), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Green.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .size(width = 10.dp, height = 2.dp) + .background( + color = Color.White, + shape = CircleShape, + ), + ) + } +} + +@PreviewTvSpec +@Composable +private fun Preview() { + WholphinTheme { + Column { + PendingIndicator() + AvailableIndicator() + PartiallyAvailableIndicator() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index 4340466c..73cf5ba3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -16,11 +16,9 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -35,7 +33,6 @@ import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import timber.log.Timber import java.util.UUID @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index ec8feca0..ec2f4453 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -173,11 +173,12 @@ class GenreViewModel } data class Genre( - override val id: UUID, + val id: UUID, val name: String, val imageUrl: String?, val color: Color, ) : CardGridItem { + override val gridId: String get() = id.toString() override val playable: Boolean = false override val sortName: String get() = name } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 747e726a..2856f319 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -179,10 +179,12 @@ fun ExpandablePlayButton( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, mirrorIcon: Boolean = false, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = { onClick.invoke(resume) }, + enabled = enabled, modifier = modifier.requiredSizeIn( minWidth = MinButtonSize, @@ -234,6 +236,7 @@ fun ExpandableFaButton( val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = onClick, + enabled = enabled, modifier = modifier.requiredSizeIn( minWidth = MinButtonSize, @@ -242,7 +245,6 @@ fun ExpandableFaButton( ), contentPadding = DefaultButtonPadding, interactionSource = interactionSource, - enabled = enabled, ) { Box( modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 53b6b9b8..f028ed1e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import java.util.UUID private const val DEBUG = false interface CardGridItem { - val id: UUID + val gridId: String val playable: Boolean val sortName: String } @@ -244,7 +243,7 @@ fun CardGrid( } else if (isPlayKeyUp(it)) { val item = pager.getOrNull(focusedIndex) if (item?.playable == true) { - Timber.v("Clicked play on ${item.id}") + Timber.v("Clicked play on ${item.gridId}") onClickPlay.invoke(focusedIndex, item) } return@onKeyEvent true @@ -375,7 +374,7 @@ fun CardGrid( pager .getOrNull(focusedIndex) ?.sortName - ?.first() + ?.firstOrNull() ?.uppercaseChar() ?.let { if (it >= '0' && it <= '9') { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 4af65f1d..91cd66f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -25,7 +24,6 @@ import java.util.UUID fun CollectionFolderBoxSet( preferences: UserPreferences, itemId: UUID, - item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, filter: CollectionFolderFilter = CollectionFolderFilter(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index e76b54cd..d3c52bf7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -62,7 +62,7 @@ fun CollectionFolderMovie( val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } +// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { logTab("movie", selectedTabIndex) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 47547219..66ace2d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -22,7 +21,6 @@ import java.util.UUID fun CollectionFolderPlaylist( preferences: UserPreferences, itemId: UUID, - item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, filter: CollectionFolderFilter = CollectionFolderFilter(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 60d8455e..8a210c27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -78,7 +78,6 @@ fun buildMoreDialogItems( Destination.Playback( item.id, item.resumeMs ?: 0L, - item, ), ) }, @@ -166,6 +165,7 @@ fun buildMoreDialogItems( Destination.MediaItem( seriesId, BaseItemKind.SERIES, + null, ), ) }, @@ -200,7 +200,6 @@ fun buildMoreDialogItems( Destination.Playback( item.id, item.resumeMs ?: 0L, - item, forceTranscoding = true, ), ) @@ -309,6 +308,7 @@ fun buildMoreDialogItemsForHome( Destination.MediaItem( it, BaseItemKind.SERIES, + null, ), ) }, @@ -328,7 +328,7 @@ fun buildMoreDialogItemsForPerson( context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { - actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON)) + actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null)) }, ) add( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 1f28720d..17535577 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -40,9 +41,11 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -57,6 +60,8 @@ import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.formatDate import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -67,11 +72,14 @@ import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RowLoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @@ -90,10 +98,12 @@ class PersonViewModel api: ApiClient, val navigationManager: NavigationManager, private val favoriteWatchManager: FavoriteWatchManager, + private val seerrService: SeerrService, ) : LoadingItemViewModel(api) { val movies = MutableLiveData(RowLoadingState.Pending) val series = MutableLiveData(RowLoadingState.Pending) val episodes = MutableLiveData(RowLoadingState.Pending) + val discovered = MutableStateFlow>(listOf()) fun init(itemId: UUID) { viewModelScope.launchIO( @@ -119,6 +129,10 @@ class PersonViewModel } else { episodes.setValueOnMain(RowLoadingState.Success(listOf())) } + viewModelScope.launchIO { + val results = seerrService.similar(person).orEmpty() + discovered.update { results } + } } } } @@ -181,6 +195,7 @@ fun PersonPage( val movies by viewModel.movies.observeAsState(RowLoadingState.Pending) val series by viewModel.series.observeAsState(RowLoadingState.Pending) val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending) + val discovered by viewModel.discovered.collectAsState() val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val state = loading) { @@ -219,6 +234,10 @@ fun PersonPage( favoriteOnClick = { viewModel.setFavorite(!person.favorite) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigationManager.navigateTo(item.destination) + }, modifier = modifier, ) AnimatedVisibility(showOverviewDialog) { @@ -243,6 +262,7 @@ private const val HEADER_ROW = 0 private const val MOVIE_ROW = 1 private const val SERIES_ROW = 2 private const val EPISODE_ROW = 3 +private const val DISCOVER_ROW = EPISODE_ROW + 1 @Composable fun PersonPageContent( @@ -257,9 +277,11 @@ fun PersonPageContent( movies: RowLoadingState, series: RowLoadingState, episodes: RowLoadingState, + discovered: List, onClickItem: (Int, BaseItem) -> Unit, overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -359,6 +381,24 @@ fun PersonPageContent( }, ) } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = RowColumn(DISCOVER_ROW, index) + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequester, + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt new file mode 100644 index 00000000..7a308d40 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -0,0 +1,458 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.LocalTrailer +import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull + +@Composable +fun DiscoverMovieDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverMovieViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), +) { + val context = LocalContext.current + LifecycleResumeEffect(Unit) { + viewModel.init() + onPauseOrDispose { } + } + val item by viewModel.movie.observeAsState() + val rating by viewModel.rating.observeAsState(null) + val people by viewModel.people.observeAsState(listOf()) + val trailers by viewModel.trailers.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.similar.observeAsState(listOf()) + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val userConfig by viewModel.userConfig.collectAsState(null) + + var overviewDialog by remember { mutableStateOf(null) } + var moreDialog by remember { mutableStateOf(null) } + + val moreActions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> }, + onClickFavorite = { itemId, favorite -> }, + onClickAddPlaylist = { itemId -> }, + ) + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { movie -> + DiscoverMovieDetailsContent( + preferences = preferences, + movie = movie, + userConfig = userConfig, + rating = rating, + people = people, + trailers = trailers, + similar = similar, + recommended = recommended, + requestOnClick = { + movie.id?.let { viewModel.request(it) } + }, + cancelOnClick = { + movie.id?.let { viewModel.cancelRequest(it) } + }, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.title ?: context.getString(R.string.unknown), + overview = movie.overview, + genres = movie.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + goToOnClick = { + movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = listOf(), + ) + }, + onLongClickPerson = { index, person -> }, + onLongClickSimilar = { index, similar -> + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + modifier = modifier, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +private const val HEADER_ROW = 0 +private const val PEOPLE_ROW = HEADER_ROW + 1 +private const val CHAPTER_ROW = PEOPLE_ROW + 1 +private const val EXTRAS_ROW = CHAPTER_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 + +@Composable +fun DiscoverMovieDetailsContent( + preferences: UserPreferences, + userConfig: SeerrUserConfig?, + movie: MovieDetails, + rating: DiscoverRating?, + people: List, + trailers: List, + similar: List, + recommended: List, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, DiscoverItem) -> Unit, + onClickPerson: (DiscoverItem) -> Unit, + onLongClickPerson: (Int, DiscoverItem) -> Unit, + onLongClickSimilar: (Int, DiscoverItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var position by rememberInt(0) + val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } + + val bringIntoViewRequester = remember { BringIntoViewRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Box(modifier = modifier) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + DiscoverMovieDetailsHeader( + preferences = preferences, + movie = movie, + rating = rating, + bringIntoViewRequester = bringIntoViewRequester, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + val canCancel = + remember(movie, userConfig) { + ( + // User requested this + userConfig.hasPermission(SeerrPermission.REQUEST) && + movie.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true + ) || + userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS) + } + ExpandableDiscoverButtons( + availability = + SeerrAvailability.from(movie.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + requestOnClick = requestOnClick, + cancelOnClick = cancelOnClick, + moreOnClick = moreOnClick, + goToOnClick = goToOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canCancel = canCancel, + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + if (people.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.people), + DataLoadingState.Success(people), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = PEOPLE_ROW + onClickPerson.invoke(item) + }, + onLongClickItem = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(index, person) + }, + onCardFocus = {}, + focusRequester = focusRequesters[PEOPLE_ROW], + ) + } + } + + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = { index, item -> + position = SIMILAR_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + if (recommended.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.recommended), + items = similar, + onClickItem = { index, item -> + position = RECOMMENDED_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = RECOMMENDED_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[RECOMMENDED_ROW]), + ) + } + } + } + } +} + +@Composable +fun TrailerRow( + trailers: List, + onClickTrailer: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.trailers), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(trailers) { index, item -> + val cardModifier = + if (index == 0) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + when (item) { + is LocalTrailer -> { + SeasonCard( + item = item.baseItem, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + showImageOverlay = false, + modifier = cardModifier, + ) + } + + is RemoteTrailer -> { + val subtitle = + when (item.url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + SeasonCard( + title = item.name, + subtitle = subtitle, + name = item.name, + imageUrl = null, + isFavorite = false, + isPlayed = false, + unplayedItemCount = 0, + playedPercentage = 0.0, + numberOfVersions = -1, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + modifier = cardModifier, + showImageOverlay = false, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt new file mode 100644 index 00000000..c25f3ff0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -0,0 +1,150 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch +import java.util.Locale +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverMovieDetailsHeader( + preferences: UserPreferences, + movie: MovieDetails, + rating: DiscoverRating?, + bringIntoViewRequester: BringIntoViewRequester, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + // Title + Text( + text = movie.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 4.dp + val details = + remember(movie) { + buildList { + movie.releaseDate?.let(::add) + movie.runtime + ?.toDouble() + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + val release = + movie.releases + ?.results + ?.firstOrNull { it.iso31661 == Locale.getDefault().country } + ?: movie.releases + ?.results + ?.firstOrNull { it.iso31661 == Locale.US.country } + ?: movie.releases + ?.results + ?.firstOrNull() + + release + ?.releaseDates + ?.firstOrNull() + ?.certification + ?.let(::add) + } + } + + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = Modifier, + ) + movie.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + + movie.tagline?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier, + ) + } + + // Description + movie.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + + val directorName = + remember(movie.credits?.crew) { + movie.credits + ?.crew + ?.filter { it.job == "Directing" } + ?.joinToString(", ") { it.name!! } + } + + directorName + ?.let { + Text( + text = stringResource(R.string.directed_by, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt new file mode 100644 index 00000000..b267e74e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -0,0 +1,171 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo +import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient + +@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) +class DiscoverMovieViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val backdropService: BackdropService, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val seerrServerRepository: SeerrServerRepository, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverMovieViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val movie = MutableLiveData(null) + val rating = MutableLiveData(null) + + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) + val recommended = MutableLiveData>(listOf()) + + val userConfig = seerrServerRepository.current.map { it?.config } + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + this@DiscoverMovieViewModel.movie.setValueOnMain(movie) + movie + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = fetchAndSetItem().await() + val discoveredItem = DiscoverItem(movie) + backdropService.submit(discoveredItem) + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + if (!similar.isInitialized) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + } + val people = + movie.credits + ?.cast + ?.map(::DiscoverItem) + .orEmpty() + + movie.credits + ?.crew + ?.map(::DiscoverItem) + .orEmpty() + this@DiscoverMovieViewModel.people.setValueOnMain(people) + val trailers = + movie.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, null) + }.orEmpty() + this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + fun request(id: Int) { + viewModelScope.launchIO { + val request = + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = false, + mediaId = id, + mediaType = RequestPostRequest.MediaType.MOVIE, + ), + ) + fetchAndSetItem().await() + } + } + + fun cancelRequest(id: Int) { + viewModelScope.launchIO { + movie.value?.mediaInfo?.requests?.firstOrNull()?.let { + // TODO handle multiple requests? Or just delete self's request? + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + fetchAndSetItem().await() + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt new file mode 100644 index 00000000..32d38aa5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -0,0 +1,161 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +@HiltViewModel(assistedFactory = DiscoverPersonViewModel.Factory::class) +class DiscoverPersonViewModel + @AssistedInject + constructor( + val navigationManager: NavigationManager, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val backdropService: BackdropService, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverPersonViewModel + } + + val credits = MutableStateFlow>>(DataLoadingState.Pending) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + + val credits = + seerrService.api.personApi + .personPersonIdCombinedCreditsGet(personId = item.id) + .let { credits -> + val cast = + credits.cast + ?.map(::DiscoverItem) + .orEmpty() + val crew = + credits.crew + ?.map(::DiscoverItem) + .orEmpty() + cast + crew + } + this@DiscoverPersonViewModel.credits.update { + DataLoadingState.Success(credits) + } + } + } + } + +@Composable +fun DiscoverPersonPage( + person: DiscoverItem, + modifier: Modifier = Modifier, + viewModel: DiscoverPersonViewModel = + hiltViewModel( + creationCallback = { it.create(person) }, + ), +) { + val credits by viewModel.credits.collectAsState() + + when (val state = credits) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier.focusable()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Success> -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.data.isNotEmpty()) { + focusRequester.tryRequestFocus() + } + } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.discover) + (person.title?.let { ": $it" } ?: ""), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (state.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } else { + CardGrid( + pager = state.data, + onClickItem = { index: Int, item: DiscoverItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + }, + onLongClickItem = { index: Int, item: DiscoverItem -> + }, + onClickPlay = { _, item -> + }, + letterPosition = { c: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 16.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + columns = 6, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt new file mode 100644 index 00000000..458a1f43 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt @@ -0,0 +1,38 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.tv.material3.MaterialTheme +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import timber.log.Timber + +@Composable +fun DiscoverQuickDetails( + item: DiscoverItem?, + rating: DiscoverRating?, + modifier: Modifier = Modifier, +) { + Timber.v("id=${item?.id}, rating=$rating") + val context = LocalContext.current + val details = + remember(item) { + buildList { + item + ?.releaseDate + ?.year + ?.toString() + ?.let(::add) + } + } + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt new file mode 100644 index 00000000..5a8d5610 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -0,0 +1,527 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverSeriesDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverSeriesViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), +) { + val context = LocalContext.current + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + + val item by viewModel.tvSeries.observeAsState() + val seasons by viewModel.seasons.observeAsState(listOf()) + val people by viewModel.people.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.recommended.observeAsState(listOf()) + val userConfig by viewModel.userConfig.collectAsState(null) + + var overviewDialog by remember { mutableStateOf(null) } + var seasonDialog by remember { mutableStateOf(null) } + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { item -> + val rating by viewModel.rating.observeAsState(null) + DiscoverSeriesDetailsContent( + preferences = preferences, + series = item, + userConfig = userConfig, + rating = rating, + seasons = seasons, + people = people, + similar = similar, + recommended = recommended, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + }, + goToOnClick = { + item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = item.name ?: context.getString(R.string.unknown), + overview = item.overview, + genres = item.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + trailers = listOf(), + requestOnClick = { + item.id?.let { viewModel.request(it) } + }, + cancelOnClick = { + item.id?.let { viewModel.cancelRequest(it) } + }, + moreOnClick = { + }, + onLongClickPerson = { _, _ -> }, + onLongClickSimilar = { _, _ -> }, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + seasonDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + waitToLoad = params.fromLongClick, + onDismissRequest = { seasonDialog = null }, + ) + } +} + +private const val HEADER_ROW = 0 +private const val SEASONS_ROW = HEADER_ROW + 1 +private const val PEOPLE_ROW = SEASONS_ROW + 1 +private const val EXTRAS_ROW = PEOPLE_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 + +@Composable +fun DiscoverSeriesDetailsContent( + preferences: UserPreferences, + userConfig: SeerrUserConfig?, + series: TvDetails, + rating: DiscoverRating?, + seasons: List, + similar: List, + recommended: List, + trailers: List, + people: List, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, DiscoverItem) -> Unit, + onClickPerson: (DiscoverItem) -> Unit, + onLongClickPerson: (Int, DiscoverItem) -> Unit, + onLongClickSimilar: (Int, DiscoverItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + var position by rememberInt() + val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val playFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + var moreDialog by remember { mutableStateOf(null) } + + Box( + modifier = modifier, + ) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) { + LazyColumn( + contentPadding = PaddingValues(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier, + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + DiscoverSeriesDetailsHeader( + series = series, + rating = rating, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + val canCancel = + remember(series, userConfig) { + ( + // User requested this + userConfig.hasPermission(SeerrPermission.REQUEST) && + series.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true + ) || + userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS) + } + ExpandableDiscoverButtons( + availability = + SeerrAvailability.from(series.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + requestOnClick = requestOnClick, + cancelOnClick = cancelOnClick, + moreOnClick = moreOnClick, + goToOnClick = goToOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canCancel = canCancel, + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } +// item { +// ItemRow( +// title = stringResource(R.string.tv_seasons) + " (${seasons.size})", +// items = seasons, +// onClickItem = { index, item -> +// position = SEASONS_ROW +// // onClickItem.invoke(index, item) +// }, +// onLongClickItem = { index, item -> +// position = SEASONS_ROW +// }, +// modifier = +// Modifier +// .fillMaxWidth() +// .focusRequester(focusRequesters[SEASONS_ROW]), +// cardContent = @Composable { index, item, mod, onClick, onLongClick -> +// SeasonCard( +// item = item, +// onClick = onClick, +// onLongClick = onLongClick, +// imageHeight = Cards.height2x3, +// imageWidth = Dp.Unspecified, +// showImageOverlay = true, +// modifier = mod, +// ) +// }, +// ) +// } + if (people.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.people), + DataLoadingState.Success(people), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = PEOPLE_ROW + onClickPerson.invoke(item) + }, + onLongClickItem = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(index, person) + }, + onCardFocus = {}, + focusRequester = focusRequesters[PEOPLE_ROW], + ) + } + } + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = { index, item -> + position = SIMILAR_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + if (recommended.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.recommended), + items = similar, + onClickItem = { index, item -> + position = RECOMMENDED_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = RECOMMENDED_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[RECOMMENDED_ROW]), + ) + } + } + } + } + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +@Composable +fun DiscoverSeriesDetailsHeader( + series: TvDetails, + rating: DiscoverRating?, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = series.name ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 4.dp + val details = + remember(series) { + buildList { + series.firstAirDate?.let(::add) + series.episodeRunTime + ?.average() + ?.takeIf { !it.isNaN() && it > 0 } + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + // TODO + } + } + + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = Modifier, + ) + series.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + series.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + ) + } + } + } +} + +fun buildDialogForSeason( + context: Context, + s: BaseItem, + onClickItem: (BaseItem) -> Unit, + markPlayed: (Boolean) -> Unit, + onClickPlay: (Boolean) -> Unit, +): DialogParams { + val items = + buildList { + add( + DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + onClickItem.invoke(s) + }, + ) + if (s.data.userData?.played == true) { + add( + DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + markPlayed.invoke(false) + }, + ) + } else { + add( + DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + markPlayed.invoke(true) + }, + ) + } + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onClickPlay.invoke(false) + }, + ) + add( + DialogItem( + context.getString(R.string.shuffle), + R.string.fa_shuffle, + ) { + onClickPlay.invoke(true) + }, + ) + } + return DialogParams( + title = s.name ?: context.getString(R.string.tv_season), + fromLongClick = true, + items = items, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt new file mode 100644 index 00000000..bfbb5bf6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -0,0 +1,163 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient + +@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) +class DiscoverSeriesViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val backdropService: BackdropService, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val seerrServerRepository: SeerrServerRepository, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverSeriesViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val tvSeries = MutableLiveData(null) + val rating = MutableLiveData(null) + + val seasons = MutableLiveData>(listOf()) + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) + val recommended = MutableLiveData>(listOf()) + + val userConfig = seerrServerRepository.current.map { it?.config } + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv) + tv + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = fetchAndSetItem().await() + val discoveredItem = DiscoverItem(tv) + backdropService.submit(discoveredItem) + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + if (!similar.isInitialized) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + } + val people = + tv.credits + ?.cast + ?.map(::DiscoverItem) + .orEmpty() + + tv.credits + ?.crew + ?.map(::DiscoverItem) + .orEmpty() + this@DiscoverSeriesViewModel.people.setValueOnMain(people) + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + fun request(id: Int) { + viewModelScope.launchIO { + val request = + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = false, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, + seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons + ), + ) + fetchAndSetItem().await() + } + } + + fun cancelRequest(id: Int) { + viewModelScope.launchIO { + tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let { + // TODO handle multiple requests? Or just delete self's request? + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + fetchAndSetItem().await() + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt new file mode 100644 index 00000000..725acbb1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -0,0 +1,154 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.TrailerButton +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlin.time.Duration + +@Composable +fun ExpandableDiscoverButtons( + canRequest: Boolean, + canCancel: Boolean, + availability: SeerrAvailability, + trailers: List?, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + buttonOnFocusChanged: (FocusState) -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus = remember { FocusRequester() } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + val text = + when (availability) { + SeerrAvailability.UNKNOWN -> R.string.request + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> R.string.pending + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> R.string.go_to + + SeerrAvailability.DELETED -> R.string.delete // TODO + } + val icon = + when (availability) { + SeerrAvailability.UNKNOWN -> R.string.fa_download + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> R.string.fa_clock + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> R.string.fa_play + + SeerrAvailability.DELETED -> R.string.fa_video // TODO + } + item("first") { + ExpandableFaButton( + title = text, + iconStringRes = icon, + enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true, + onClick = { + when (availability) { + SeerrAvailability.UNKNOWN -> { + requestOnClick.invoke() + } + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + // TODO? + } + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> { + goToOnClick.invoke() + } + + SeerrAvailability.DELETED -> { + // TODO + } + } + }, + modifier = + Modifier + .focusRequester(firstFocus) + .onFocusChanged(buttonOnFocusChanged), + ) + } + + if (canCancel) { + item("cancel") { + ExpandablePlayButton( + title = R.string.cancel, + icon = Icons.Default.Delete, + onClick = { + firstFocus.tryRequestFocus() + cancelOnClick.invoke() + }, + resume = Duration.ZERO, + enabled = canCancel, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } + + if (trailers != null) { + item("trailers") { + TrailerButton( + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + + // More button + item("more") { + ExpandablePlayButton( + R.string.more, + Duration.ZERO, + Icons.Default.MoreVert, + { moreOnClick.invoke() }, + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index cb9e2977..c46fe0cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -130,7 +130,6 @@ fun EpisodeDetails( Destination.Playback( ep.id, it.inWholeMilliseconds, - ep, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 0a0f6afc..58e7762d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.aspectRatioFloat @@ -61,8 +63,11 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -99,6 +104,7 @@ fun MovieDetails( val similar by viewModel.similar.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val discovered by viewModel.discovered.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -168,7 +174,6 @@ fun MovieDetails( Destination.Playback( movie.id, it.inWholeMilliseconds, - movie, ), ) }, @@ -300,6 +305,10 @@ fun MovieDetails( onClickExtra = { index, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, modifier = modifier, ) } @@ -360,6 +369,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val CHAPTER_ROW = TRAILER_ROW + 1 private const val EXTRAS_ROW = CHAPTER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun MovieDetailsContent( @@ -371,6 +381,7 @@ fun MovieDetailsContent( trailers: List, extras: List, similar: List, + discovered: List, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, @@ -382,12 +393,13 @@ fun MovieDetailsContent( onLongClickPerson: (Int, Person) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val scope = rememberCoroutineScope() var position by rememberInt(0) - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val dto = movie.data val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO @@ -543,6 +555,24 @@ fun MovieDetailsContent( ) } } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index e7bd7168..8454f0a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -40,6 +42,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -54,6 +58,7 @@ class MovieViewModel @AssistedInject constructor( private val api: ApiClient, + private val seerrService: SeerrService, @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, @@ -81,6 +86,7 @@ class MovieViewModel val extras = MutableLiveData>(listOf()) val similar = MutableLiveData>() val chosenStreams = MutableLiveData(null) + val discovered = MutableStateFlow>(listOf()) init { init() @@ -140,6 +146,10 @@ class MovieViewModel val extras = extrasService.getExtras(item.id) this@MovieViewModel.extras.setValueOnMain(extras) } + viewModelScope.launchIO { + val results = seerrService.similar(item).orEmpty() + discovered.update { results } + } withContext(Dispatchers.Main) { chapters.value = Chapter.fromDto(item.data, api) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 7546ca56..c63ead92 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -40,6 +41,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.UserPreferences @@ -71,9 +73,12 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -104,6 +109,7 @@ fun SeriesDetails( val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) + val discovered by viewModel.discovered.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } @@ -208,6 +214,10 @@ fun SeriesDetails( onClickExtra = { _, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, moreActions = MoreDialogActions( navigateTo = { viewModel.navigateTo(it) }, @@ -281,6 +291,7 @@ private const val PEOPLE_ROW = SEASONS_ROW + 1 private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val EXTRAS_ROW = TRAILER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun SeriesDetailsContent( @@ -291,6 +302,7 @@ fun SeriesDetailsContent( trailers: List, extras: List, people: List, + discovered: List, played: Boolean, favorite: Boolean, onClickItem: (Int, BaseItem) -> Unit, @@ -303,6 +315,7 @@ fun SeriesDetailsContent( trailerOnClick: (Trailer) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, moreActions: MoreDialogActions, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -310,7 +323,7 @@ fun SeriesDetailsContent( val bringIntoViewRequester = remember { BringIntoViewRequester() } var position by rememberInt() - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } RequestOrRestoreFocus(focusRequesters.getOrNull(position)) var moreDialog by remember { mutableStateOf(null) } @@ -546,6 +559,24 @@ fun SeriesDetailsContent( ) } } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 7553c9e7..718e73dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -312,7 +312,6 @@ fun SeriesOverview( Destination.Playback( it.id, resumePosition.inWholeMilliseconds, - it, ), ) }, @@ -327,7 +326,6 @@ fun SeriesOverview( Destination.Playback( it.id, resume.inWholeMilliseconds, - it, ), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 16c6cb37..9d6f1e94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -18,6 +19,7 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -84,6 +86,7 @@ class SeriesViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val seerrService: SeerrService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -107,6 +110,7 @@ class SeriesViewModel val similar = MutableLiveData>() val peopleInEpisode = MutableLiveData(PeopleInItem()) + val discovered = MutableStateFlow>(listOf()) val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) @@ -210,6 +214,10 @@ class SeriesViewModel this@SeriesViewModel.similar.setValueOnMain(similar) } } + viewModelScope.launchIO { + val results = seerrService.similar(item).orEmpty() + discovered.update { results } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt new file mode 100644 index 00000000..4d3bdc42 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun DiscoverPage( + preferences: UserPreferences, + modifier: Modifier = Modifier, + preferencesViewModel: PreferencesViewModel = hiltViewModel(), +) { + val rememberedTabIndex = + remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } + + val tabs = + listOf( + stringResource(R.string.discover), + stringResource(R.string.request), + ) + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + logTab("discover", selectedTabIndex) + preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) + preferencesViewModel.backdropService.clearBackdrop() + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, + ) + } + when (selectedTabIndex) { + // Discover + 0 -> { + SeerrDiscoverPage( + preferences = preferences, + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + // Requests + 1 -> { + SeerrRequestsPage( + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + else -> { + ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt new file mode 100644 index 00000000..ac307f21 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -0,0 +1,322 @@ +package com.github.damontecres.wholphin.ui.discover + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverQuickDetails +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.HomePageHeader +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.google.common.cache.CacheBuilder +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import javax.inject.Inject + +@HiltViewModel +class SeerrDiscoverViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val api: ApiClient, + private val backdropService: BackdropService, + ) : ViewModel() { + val state = MutableStateFlow(DiscoverState()) + val rating = MutableStateFlow>(mapOf()) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + } + fetchAndUpdateState(seerrService::discoverMovies) { + this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it)) + } + fetchAndUpdateState(seerrService::discoverTv) { + this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it)) + } + fetchAndUpdateState(seerrService::trending) { + this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it)) + } + fetchAndUpdateState(seerrService::upcomingMovies) { + this.copy( + upcomingMovies = + DiscoverRowData(context.getString(R.string.upcoming_movies), it), + ) + } + fetchAndUpdateState(seerrService::upcomingTv) { + this.copy( + upcomingTv = + DiscoverRowData(context.getString(R.string.upcoming_tv), it), + ) + } + } + + private fun fetchAndUpdateState( + getData: suspend () -> List, + copyFunc: DiscoverState.(DataLoadingState>) -> DiscoverState, + ) { + viewModelScope.launchIO { + state.update { + copyFunc.invoke(it, DataLoadingState.Loading) + } + try { + val results = getData.invoke() + state.update { + copyFunc.invoke(it, DataLoadingState.Success(results)) + } + } catch (ex: Exception) { + state.update { + copyFunc.invoke(it, DataLoadingState.Error(ex)) + } + } + } + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + fetchRating(item) + } + } + } + + private val ratingCache = + CacheBuilder + .newBuilder() + .maximumSize(100) + .build() + + // TODO this is not very efficient + fun fetchRating(item: DiscoverItem) { + viewModelScope.launchIO { + val cachedResult = ratingCache.getIfPresent(item.id) + if (cachedResult != null) { + return@launchIO + } + val result = + when (item.type) { + SeerrItemType.MOVIE -> { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet( + movieId = item.id, + ), + ) + } + + SeerrItemType.TV -> { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } + + SeerrItemType.PERSON -> { + DiscoverRating(null, null) + } + + SeerrItemType.UNKNOWN -> { + DiscoverRating(null, null) + } + } + ratingCache.put(item.id, result) + rating.update { + ratingCache.asMap().toMap() + } + } + } + } + +data class DiscoverRowData( + val title: String, + val items: DataLoadingState>, +) { + companion object { + val EMPTY = DiscoverRowData("", DataLoadingState.Pending) + } +} + +data class DiscoverState( + val movies: DiscoverRowData = DiscoverRowData.EMPTY, + val tv: DiscoverRowData = DiscoverRowData.EMPTY, + val trending: DiscoverRowData = DiscoverRowData.EMPTY, + val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, + val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, +) + +@Composable +fun SeerrDiscoverPage( + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SeerrDiscoverViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val rows = + listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) + val ratingMap by viewModel.rating.collectAsState() + + val focusRequesters = remember(2) { List(rows.size) { FocusRequester() } } + var position by rememberPosition(0, -1) + val focusedItem = + remember(position) { + position.let { + (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column) + } + } + LaunchedEffect(focusedItem) { + viewModel.updateBackdrop(focusedItem) + } + var firstFocused by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(state.trending) { + if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) { + firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true + } + } + + Column( + modifier = modifier, + ) { + HomePageHeader( + title = focusedItem?.title, + subtitle = focusedItem?.subtitle, + overview = focusedItem?.overview, + overviewTwoLines = true, + quickDetails = { + DiscoverQuickDetails( + item = focusedItem, + rating = focusedItem?.id?.let { ratingMap[it] }, + ) + }, + modifier = + Modifier + .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) + .fillMaxHeight(.25f), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), + ) { + itemsIndexed(rows) { rowIndex, row -> + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + focusRequester = focusRequesters[rowIndex], + modifier = + Modifier + .fillMaxWidth(), + ) + } + } + } +} + +@Composable +fun DiscoverRow( + row: DiscoverRowData, + onClickItem: (Int, DiscoverItem) -> Unit, + onLongClickItem: (Int, DiscoverItem) -> Unit, + onCardFocus: (Int) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + when (val state = row.items) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = row.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success> -> { + ItemRow( + title = row.title, + items = state.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = + mod.onFocusChanged { + if (it.isFocused) { + onCardFocus.invoke(index) + } + }, + ) + }, + modifier = modifier.focusRequester(focusRequester), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt new file mode 100644 index 00000000..6746950a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -0,0 +1,219 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MediaRequest +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SeerrRequestsViewModel + @Inject + constructor( + private val seerrServerRepository: SeerrServerRepository, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val backdropService: BackdropService, + ) : ViewModel() { + val state = MutableStateFlow(SeerrRequestsState.EMPTY) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + } + seerrServerRepository.current + .onEach { user -> + state.update { it.copy(requests = DataLoadingState.Loading) } + if (user != null) { + val semaphore = Semaphore(3) + val mediaRequests = + seerrService.api.requestApi + .requestGet() + .results + .orEmpty() + val requests = + mediaRequests.mapNotNull { request -> + if (request.media?.tmdbId != null) { + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + val type = SeerrItemType.fromString(request.type) + when (type) { + SeerrItemType.MOVIE -> { + seerrService.api.moviesApi + .movieMovieIdGet( + movieId = request.media.tmdbId, + ).let { DiscoverItem(it) } + } + + SeerrItemType.TV -> { + seerrService.api.tvApi + .tvTvIdGet(tvId = request.media.tmdbId) + .let { DiscoverItem(it) } + } + + SeerrItemType.PERSON -> { + null + } + + SeerrItemType.UNKNOWN -> { + null + } + }?.let { RequestGridItem(request, it) } + } + } + } else { + Timber.v("No TMDB ID for request %s", request.id) + null + } + } + val results = requests.awaitAll().filterNotNull() + + state.update { it.copy(requests = DataLoadingState.Success(results)) } + } + }.launchIn(viewModelScope) + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + } + } + } + } + +data class SeerrRequestsState( + val requests: DataLoadingState>, +) { + companion object { + val EMPTY = SeerrRequestsState(DataLoadingState.Pending) + } +} + +data class RequestGridItem( + val request: MediaRequest, + val item: DiscoverItem, +) : CardGridItem { + override val gridId: String = request.id.toString() + override val playable: Boolean = false + override val sortName: String = request.updatedAt ?: "0000" +} + +@Composable +fun SeerrRequestsPage( + focusRequesterOnEmpty: FocusRequester?, + modifier: Modifier = Modifier, + viewModel: SeerrRequestsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY) + + when (val state = state.requests) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier.focusable()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Success> -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.data.isNotEmpty()) { + focusRequester.tryRequestFocus() + } else { + focusRequesterOnEmpty?.tryRequestFocus() + } + } + Column(modifier = modifier) { +// Text( +// text = stringResource(R.string.request), +// style = MaterialTheme.typography.displaySmall, +// color = MaterialTheme.colorScheme.onBackground, +// textAlign = TextAlign.Center, +// modifier = Modifier.fillMaxWidth(), +// ) + if (state.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } else { + CardGrid( + pager = state.data, + onClickItem = { index: Int, item: RequestGridItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item.item)) + }, + onLongClickItem = { index: Int, item: RequestGridItem -> + }, + onClickPlay = { _, item -> + }, + letterPosition = { c: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 16.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item?.item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + columns = 6, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 97d48772..4a95b4e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -444,56 +444,75 @@ fun HomePageHeader( modifier: Modifier = Modifier, ) { item?.let { + val isEpisode = item.type == BaseItemKind.EPISODE val dto = item.data - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - item.title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth(.6f) - .fillMaxHeight(), - ) { - val isEpisode = item.type == BaseItemKind.EPISODE - val subtitle = if (isEpisode) dto.name else null - val overview = dto.overview - subtitle?.let { - EpisodeName(it) - } + HomePageHeader( + title = item.title, + subtitle = if (isEpisode) dto.name else null, + overview = dto.overview, + overviewTwoLines = isEpisode, + quickDetails = { when (item.type) { BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier) BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier) else -> MovieQuickDetails(dto, Modifier) } - val overviewModifier = - Modifier - .padding(0.dp) - .height(48.dp + if (!isEpisode) 12.dp else 0.dp) - .width(400.dp) - if (overview.isNotNullOrBlank()) { - Text( - text = overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = if (isEpisode) 2 else 3, - overflow = TextOverflow.Ellipsis, - modifier = overviewModifier, - ) - } else { - Spacer(overviewModifier) - } + }, + modifier = modifier, + ) + } +} + +@Composable +fun HomePageHeader( + title: String?, + subtitle: String?, + overview: String?, + overviewTwoLines: Boolean, + quickDetails: (@Composable () -> Unit)?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth(.6f) + .fillMaxHeight(), + ) { + subtitle?.let { + EpisodeName(it) + } + quickDetails?.invoke() + val overviewModifier = + Modifier + .padding(0.dp) + .height(48.dp + if (!overviewTwoLines) 12.dp else 0.dp) + .width(400.dp) + if (overview.isNotNullOrBlank()) { + Text( + text = overview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (overviewTwoLines) 2 else 3, + overflow = TextOverflow.Ellipsis, + modifier = overviewModifier, + ) + } else { + Spacer(overviewModifier) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 19b04d0b..4ca9f2c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -39,10 +39,14 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard @@ -50,7 +54,10 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,6 +65,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -72,11 +80,13 @@ class SearchViewModel constructor( val api: ApiClient, val navigationManager: NavigationManager, + private val seerrService: SeerrService, ) : ViewModel() { val movies = MutableLiveData(SearchResult.NoQuery) val series = MutableLiveData(SearchResult.NoQuery) val episodes = MutableLiveData(SearchResult.NoQuery) val collections = MutableLiveData(SearchResult.NoQuery) + val seerrResults = MutableLiveData(SearchResult.NoQuery) private var currentQuery: String? = null @@ -94,11 +104,13 @@ class SearchViewModel searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.EPISODE, episodes) searchInternal(query, BaseItemKind.BOX_SET, collections) + searchSeerr(query) } else { movies.value = SearchResult.NoQuery series.value = SearchResult.NoQuery episodes.value = SearchResult.NoQuery collections.value = SearchResult.NoQuery + seerrResults.value = SearchResult.NoQuery } } @@ -132,6 +144,20 @@ class SearchViewModel } } + private fun searchSeerr(query: String) { + viewModelScope.launchIO { + if (seerrService.active.first()) { + seerrResults.setValueOnMain(SearchResult.Searching) + val results = + seerrService + .search(query) + .map { DiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) + } + } + } + fun getHints(query: String) { // TODO // api.searchApi.getSearchHints() @@ -150,12 +176,17 @@ sealed interface SearchResult { data class Success( val items: List, ) : SearchResult + + data class SuccessSeerr( + val items: List, + ) : SearchResult } private const val MOVIE_ROW = 0 private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val SERIES_ROW = COLLECTION_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 +private const val SEERR_ROW = EPISODE_ROW + 1 @Composable fun SearchPage( @@ -170,6 +201,7 @@ fun SearchPage( val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) + val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } @@ -288,6 +320,30 @@ fun SearchPage( ) }, ) + searchResultRow( + title = context.getString(R.string.discover), + result = seerrResults, + rowIndex = SEERR_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = { _, _ -> + // no-op + }, + onClickDiscover = { _, item -> + val dest = + if (item.jellyfinItemId != null && item.type.baseItemKind != null) { + Destination.MediaItem( + itemId = item.jellyfinItemId, + type = item.type.baseItemKind, + ) + } else { + Destination.DiscoveredItem(item) + } + viewModel.navigationManager.navigateTo(dest) + }, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) } } @@ -300,6 +356,7 @@ fun LazyListScope.searchResultRow( onClickItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, + onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, cardContent: @Composable ( index: Int, item: BaseItem?, @@ -365,6 +422,36 @@ fun LazyListScope.searchResultRow( ) } } + + is SearchResult.SuccessSeerr -> { + if (r.items.isEmpty()) { + SearchResultPlaceholder( + title = title, + message = stringResource(R.string.no_results), + modifier = modifier, + ) + } else { + ItemRow( + title = title, + items = r.items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClickDiscover?.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = modifier, + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 322b0e46..426d5328 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -6,16 +6,16 @@ import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -45,7 +45,6 @@ sealed class Destination( data class SeriesOverview( val itemId: UUID, val type: BaseItemKind, - @Transient val item: BaseItem? = null, val seasonEpisode: SeasonEpisodeIds? = null, ) : Destination() { override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)" @@ -55,11 +54,9 @@ sealed class Destination( data class MediaItem( val itemId: UUID, val type: BaseItemKind, - @Transient val item: BaseItem? = null, - val seasonEpisode: SeasonEpisode? = null, + val collectionType: CollectionType? = null, ) : Destination() { - override fun toString(): String = - "MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})" + constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) } @Serializable @@ -71,13 +68,10 @@ sealed class Destination( data class Playback( val itemId: UUID, val positionMs: Long, - @Transient val item: BaseItem? = null, val itemPlayback: ItemPlayback? = null, val forceTranscoding: Boolean = false, ) : Destination(true) { - override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" - - constructor(item: BaseItem) : this(item.id, item.resumeMs, item) + constructor(item: BaseItem) : this(item.id, item.resumeMs) } @Serializable @@ -109,6 +103,14 @@ sealed class Destination( @Serializable data object Favorites : Destination(false) + @Serializable + data object Discover : Destination(false) + + @Serializable + data class DiscoveredItem( + val item: DiscoverItem, + ) : Destination(false) + @Serializable data object UpdateApp : Destination(true) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 3ebd1cb1..43b8841d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -3,8 +3,10 @@ package com.github.damontecres.wholphin.ui.nav import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo @@ -20,10 +22,14 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview +import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage @@ -121,7 +127,6 @@ fun DestinationContent( CollectionFolderBoxSet( preferences = preferences, itemId = destination.itemId, - item = destination.item, recursive = false, playEnabled = true, modifier = modifier, @@ -141,7 +146,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = null, recursiveOverride = null, modifier = modifier, @@ -153,7 +158,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = true, recursiveOverride = null, modifier = modifier, @@ -165,7 +170,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = null, recursiveOverride = true, modifier = modifier, @@ -247,6 +252,47 @@ fun DestinationContent( Destination.Debug -> { DebugPage(preferences, modifier) } + + Destination.Discover -> { + DiscoverPage( + preferences = preferences, + modifier = modifier, + ) + } + + is Destination.DiscoveredItem -> { + when (destination.item.type) { + SeerrItemType.MOVIE -> { + DiscoverMovieDetails( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + + SeerrItemType.TV -> { + DiscoverSeriesDetails( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + + SeerrItemType.PERSON -> { + DiscoverPersonPage( + person = destination.item, + modifier = modifier, + ) + } + + SeerrItemType.UNKNOWN -> { + Text( + text = "Unknown discover type", + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } } } @@ -292,7 +338,6 @@ fun CollectionFolder( CollectionFolderPlaylist( preferences, destination.itemId, - destination.item, true, modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index ff3a64b3..10c48451 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -78,6 +78,7 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome @@ -94,6 +95,8 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.CollectionType @@ -109,17 +112,25 @@ class NavDrawerViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, + private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - private var all: List? = null + // private var all: List? = null val moreLibraries = MutableLiveData>(null) val libraries = MutableLiveData>(listOf()) val selectedIndex = MutableLiveData(-1) val showMore = MutableLiveData(false) + init { + seerrServerRepository.active + .onEach { + init() + }.launchIn(viewModelScope) + } + fun init() { viewModelScope.launchIO { - val all = all ?: navDrawerItemRepository.getNavDrawerItems() - this@NavDrawerViewModel.all = all + val all = navDrawerItemRepository.getNavDrawerItems() +// this@NavDrawerViewModel.all = all val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val moreLibraries = all.toMutableList().apply { removeAll(libraries) } @@ -128,11 +139,19 @@ class NavDrawerViewModel this@NavDrawerViewModel.libraries.value = libraries } val asDestinations = - (libraries + listOf(NavDrawerItem.More) + moreLibraries).map { + ( + libraries + + listOf( + NavDrawerItem.More, + NavDrawerItem.Discover, + ) + moreLibraries + ).map { if (it is ServerNavDrawerItem) { it.destination } else if (it is NavDrawerItem.Favorites) { Destination.Favorites + } else if (it is NavDrawerItem.Discover) { + Destination.Discover } else { null } @@ -155,7 +174,7 @@ class NavDrawerViewModel null } } -// Timber.v("Found $index => $key") + Timber.v("Found $index => $key") if (index != null) { selectedIndex.setValueOnMain(index) break @@ -192,6 +211,13 @@ sealed interface NavDrawerItem { override fun name(context: Context): String = context.getString(R.string.more) } + + object Discover : NavDrawerItem { + override val id: String + get() = "a_discover" + + override fun name(context: Context): String = context.getString(R.string.discover) + } } data class ServerNavDrawerItem( @@ -267,6 +293,13 @@ fun NavDrawer( setShowMore(!showMore) } + NavDrawerItem.Discover -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer( + Destination.Discover, + ) + } + is ServerNavDrawerItem -> { viewModel.setIndex(index) viewModel.navigationManager.navigateToFromDrawer(item.destination) @@ -606,6 +639,10 @@ fun NavigationDrawerScope.NavItem( R.string.fa_ellipsis } + NavDrawerItem.Discover -> { + R.string.fa_magnifying_glass_plus + } + is ServerNavDrawerItem -> { when (library.type) { CollectionType.MOVIES -> R.string.fa_film diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 8d4954a6..151a15a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -241,13 +241,7 @@ class PlaybackViewModel "Error preparing for playback for $itemId", ), ) { - val destItem = (destination as? Destination.Playback)?.item?.data - val queriedItem = - if (destItem?.mediaSources != null) { - destItem - } else { - api.userLibraryApi.getItem(itemId).content - } + val queriedItem = api.userLibraryApi.getItem(itemId).content val base = if (queriedItem.type.playable) { queriedItem diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 71eac49f..1c10e565 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf @@ -42,6 +43,7 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend @@ -50,6 +52,7 @@ import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.uiPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -58,9 +61,12 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.UpdateViewModel +import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import timber.log.Timber @@ -71,6 +77,7 @@ fun PreferencesContent( modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), updateVM: UpdateViewModel = hiltViewModel(), + seerrVm: SwitchSeerrViewModel = hiltViewModel(), onFocus: (Int, Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current @@ -84,6 +91,8 @@ fun PreferencesContent( val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } + val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + var showSeerrServerDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -381,6 +390,22 @@ fun PreferencesContent( ) } + AppPreference.SeerrIntegration -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showSeerrServerDialog = true }, + modifier = Modifier, + summary = + if (seerrIntegrationEnabled) { + stringResource(R.string.enabled) + } else { + null + }, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -440,6 +465,39 @@ fun PreferencesContent( ) } } + if (showSeerrServerDialog) { + if (seerrIntegrationEnabled) { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = "", + onCancel = { showSeerrServerDialog = false }, + onConfirm = { + seerrVm.removeServer() + showSeerrServerDialog = false + }, + ) + } else { + val currentUser by seerrVm.currentUser.observeAsState() + val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) + LaunchedEffect(status) { + if (status == LoadingState.Success) { + showSeerrServerDialog = false + } + } + AddSeerServerDialog( + currentUsername = currentUser?.name, + status = status, + onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod -> + if (method == SeerrAuthMethod.API_KEY) { + seerrVm.submitServer(url, passwordOrApiKey) + } else { + seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method) + } + }, + onDismissRequest = { showSeerrServerDialog = false }, + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index d1e2baa2..9973bbec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao @@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem @@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo @@ -43,6 +46,7 @@ class PreferencesViewModel private val serverRepository: ServerRepository, private val navDrawerItemRepository: NavDrawerItemRepository, private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, ) : ViewModel(), @@ -52,6 +56,11 @@ class PreferencesViewModel val currentUser get() = serverRepository.currentUser + val seerrEnabled = + seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> + seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId + } + init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt new file mode 100644 index 00000000..0c74f715 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -0,0 +1,306 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun AddSeerrServerApiKey( + onSubmit: (url: String, apiKey: String) -> Unit, + status: LoadingState, + modifier: Modifier = Modifier, +) { + var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .focusGroup() + .padding(16.dp) + .wrapContentSize(), + ) { + var url by remember { mutableStateOf("") } + var apiKey by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = "Enter URL & API Key", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "URL", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = url, + onValueChange = { + error = null + url = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "API Key", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = apiKey, + onValueChange = { + error = null + apiKey = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke(url, apiKey) }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(passwordFocusRequester), + ) + } + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleLarge, + ) + } + TextButton( + stringRes = R.string.submit, + onClick = { onSubmit.invoke(url, apiKey) }, + enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && apiKey.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@Composable +fun AddSeerrServerUsername( + onSubmit: (url: String, username: String, password: String) -> Unit, + username: String, + status: LoadingState, + modifier: Modifier = Modifier, +) { + var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .focusGroup() + .padding(16.dp) + .wrapContentSize(), + ) { + var url by remember { mutableStateOf("") } + var username by remember { mutableStateOf(username) } + var password by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val usernameFocusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = stringResource(R.string.username_or_password), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + val labelWidth = 90.dp + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.url), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = url, + onValueChange = { + error = null + url = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + usernameFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.username), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = username, + onValueChange = { + error = null + username = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.password), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = password, + onValueChange = { + error = null + password = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke(url, username, password) }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(passwordFocusRequester), + ) + } + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleLarge, + ) + } + TextButton( + stringRes = R.string.submit, + onClick = { onSubmit.invoke(url, username, password) }, + enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@PreviewTvSpec +@Composable +private fun AddSeerrServerUsernamePreview() { + WholphinTheme { + AddSeerrServerUsername( + onSubmit = { string: String, string1: String, string2: String -> }, + username = "test", + status = LoadingState.Pending, + modifier = Modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt new file mode 100644 index 00000000..0feef21c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun AddSeerServerDialog( + currentUsername: String?, + status: LoadingState, + onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onDismissRequest: () -> Unit, +) { + var authMethod by remember { mutableStateOf(null) } + LaunchedEffect(status) { + if (status is LoadingState.Success) { + onDismissRequest.invoke() + } + } + when (val auth = authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + BasicDialog( + onDismissRequest = { authMethod = null }, + ) { + AddSeerrServerUsername( + onSubmit = { url, username, password -> + onSubmit.invoke(url, username, password, auth) + }, + username = currentUsername ?: "", + status = status, + ) + } + } + + SeerrAuthMethod.API_KEY -> { + BasicDialog( + onDismissRequest = { authMethod = null }, + ) { + AddSeerrServerApiKey( + onSubmit = { url, apiKey -> + onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) + }, + status = status, + ) + } + } + + null -> { + ChooseSeerrLoginType( + onDismissRequest = onDismissRequest, + onChoose = { authMethod = it }, + ) + } + } +} + +@Composable +fun ChooseSeerrLoginType( + onDismissRequest: () -> Unit, + onChoose: (SeerrAuthMethod) -> Unit, +) { + val params = + remember { + DialogParams( + fromLongClick = false, + title = "Login to Seerr server", + items = + listOf( + DialogItem( + text = "API Key", + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, + ), + DialogItem( + text = "Jellyfin user", + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = "Local user", + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + dismissOnClick = false, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt new file mode 100644 index 00000000..3ce82483 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -0,0 +1,90 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +@HiltViewModel +class SwitchSeerrViewModel + @Inject + constructor( + private val seerrServerRepository: SeerrServerRepository, + private val seerrService: SeerrService, + private val serverRepository: ServerRepository, + ) : ViewModel() { + val currentUser = serverRepository.currentUser + + val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + + private fun cleanUrl(url: String) = + if (!url.endsWith("/api/v1")) { + url + .toHttpUrlOrNull() + ?.newBuilder() + ?.apply { + addPathSegment("api") + addPathSegment("v1") + }?.build() + .toString() + } else { + url + } + + fun submitServer( + url: String, + apiKey: String, + ) { + viewModelScope.launchIO { + val url = cleanUrl(url) + val result = + seerrServerRepository.testConnection( + authMethod = SeerrAuthMethod.API_KEY, + url = url, + username = null, + passwordOrApiKey = apiKey, + ) + if (result is LoadingState.Success) { + seerrServerRepository.addAndChangeServer(url, apiKey) + } + serverConnectionStatus.update { result } + } + } + + fun submitServer( + url: String, + username: String, + password: String, + authMethod: SeerrAuthMethod, + ) { + viewModelScope.launchIO { + val url = cleanUrl(url) + val result = + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url, + username = username, + passwordOrApiKey = password, + ) + if (result is LoadingState.Success) { + seerrServerRepository.addAndChangeServer(url, authMethod, username, password) + } + serverConnectionStatus.update { result } + } + } + + fun removeServer() { + viewModelScope.launchIO { + seerrServerRepository.removeServer() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 9bf07d62..c35e9512 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -4,6 +4,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem /** * Generic state for loading something from the API + * + * @see DataLoadingState */ sealed interface LoadingState { data object Pending : LoadingState @@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } + +/** + * Generic state for loading something from the API + * + * @see LoadingState + */ +sealed interface DataLoadingState { + data object Pending : DataLoadingState + + data object Loading : DataLoadingState + + data class Success( + val data: T, + ) : DataLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : DataLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt new file mode 100644 index 00000000..89b900f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +object LocalDateSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = SerialDescriptor("LocalDate", String.serializer().descriptor) + + override fun serialize( + encoder: Encoder, + value: LocalDate, + ) { + encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE.format(value)) + } + + override fun deserialize(decoder: Decoder): LocalDate = + decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } +} diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 59814e43..d247de8c 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -45,4 +45,8 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90676968..670264ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -253,6 +253,22 @@ %d seconds + + Movies + Movie + Movies + + + TV Shows + TV Show + TV Shows + + + People + Person + People + + preference.update.threshold preference.update.lastTimestamp @@ -413,6 +429,18 @@ Direct play Dolby Vision Profile 7 Ignores device compatibility checks + Discover + Request + Pending + Seerr integration + Remove Seerr Server + Password + Username + URL + Trending + Upcoming Movies + Upcoming TV Shows + Disabled Lowest diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml new file mode 100644 index 00000000..f8efad03 --- /dev/null +++ b/app/src/main/seerr/seerr-api.yml @@ -0,0 +1,7772 @@ +openapi: '3.0.2' +info: + title: 'Seerr API' + version: '1.0.0' + description: | + This is the documentation for the Seerr API backend. + + Two primary authentication methods are supported: + + - **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie. + - **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Seerr. +tags: + - name: public + description: Public API endpoints requiring no authentication. + - name: settings + description: Endpoints related to Seerr's settings and configuration. + - name: auth + description: Endpoints related to logging in or out, and the currently authenticated user. + - name: users + description: Endpoints related to user management. + - name: search + description: Endpoints related to search and discovery. + - name: request + description: Endpoints related to request management. + - name: movies + description: Endpoints related to retrieving movies and their details. + - name: tv + description: Endpoints related to retrieving TV series and their details. + - name: other + description: Endpoints related to other TMDB data + - name: person + description: Endpoints related to retrieving person details. + - name: media + description: Endpoints related to media management. + - name: collection + description: Endpoints related to retrieving collection details. + - name: service + description: Endpoints related to getting service (Radarr/Sonarr) details. + - name: watchlist + description: Collection of media to watch later + - name: blacklist + description: Blacklisted media from discovery page. +servers: + - url: '{server}/api/v1' + variables: + server: + default: http://localhost:5055 + +components: + schemas: + Blacklist: + type: object + properties: + tmdbId: + type: integer + example: 1 + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + userId: + type: integer + example: 1 + Watchlist: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + tmdbId: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' + User: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + email: + type: string + example: 'hey@itsme.com' + readOnly: true + username: + type: string + plexUsername: + type: string + readOnly: true + plexToken: + type: string + readOnly: true + jellyfinAuthToken: + type: string + readOnly: true + userType: + type: integer + example: 1 + readOnly: true + permissions: + type: integer + example: 0 + avatar: + type: string + readOnly: true + createdAt: + type: string + example: '2020-09-02T05:02:23.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-02T05:02:23.000Z' + readOnly: true + requestCount: + type: integer + example: 5 + readOnly: true + required: + - id + UserSettings: + type: object + properties: + username: + type: string + nullable: true + example: 'Mr User' + email: + type: string + example: 'user@example.com' + discordId: + type: string + nullable: true + example: '123456789' + locale: + type: string + nullable: true + example: 'en' + discoverRegion: + type: string + nullable: true + example: 'US' + streamingRegion: + type: string + nullable: true + example: 'US' + originalLanguage: + type: string + nullable: true + example: 'en' + movieQuotaLimit: + type: integer + nullable: true + description: 'Maximum number of movie requests allowed' + example: 10 + movieQuotaDays: + type: integer + nullable: true + description: 'Time period in days for movie quota' + example: 30 + tvQuotaLimit: + type: integer + nullable: true + description: 'Maximum number of TV requests allowed' + example: 5 + tvQuotaDays: + type: integer + nullable: true + description: 'Time period in days for TV quota' + example: 14 + globalMovieQuotaDays: + type: integer + nullable: true + description: 'Global movie quota days setting' + example: 30 + globalMovieQuotaLimit: + type: integer + nullable: true + description: 'Global movie quota limit setting' + example: 10 + globalTvQuotaLimit: + type: integer + nullable: true + description: 'Global TV quota limit setting' + example: 5 + globalTvQuotaDays: + type: integer + nullable: true + description: 'Global TV quota days setting' + example: 14 + watchlistSyncMovies: + type: boolean + nullable: true + description: 'Enable watchlist sync for movies' + example: true + watchlistSyncTv: + type: boolean + nullable: true + description: 'Enable watchlist sync for TV' + example: false + MainSettings: + type: object + properties: + apiKey: + type: string + readOnly: true + appLanguage: + type: string + example: en + applicationTitle: + type: string + example: Seerr + applicationUrl: + type: string + example: https://os.example.com + hideAvailable: + type: boolean + example: false + partialRequestsEnabled: + type: boolean + example: false + localLogin: + type: boolean + example: true + mediaServerType: + type: integer + example: 1 + newPlexLogin: + type: boolean + example: true + defaultPermissions: + type: integer + example: 32 + enableSpecialEpisodes: + type: boolean + example: false + NetworkSettings: + type: object + properties: + csrfProtection: + type: boolean + example: false + forceIpv4First: + type: boolean + example: false + trustProxy: + type: boolean + example: false + proxy: + type: object + properties: + enabled: + type: boolean + example: false + hostname: + type: string + example: '' + port: + type: integer + example: 8080 + useSsl: + type: boolean + example: false + user: + type: string + example: '' + password: + type: string + example: '' + bypassFilter: + type: string + example: '' + bypassLocalAddresses: + type: boolean + example: true + dnsCache: + type: object + properties: + enabled: + type: boolean + example: false + forceMinTtl: + type: integer + example: 0 + forceMaxTtl: + type: integer + example: -1 + PlexLibrary: + type: object + properties: + id: + type: string + name: + type: string + example: Movies + enabled: + type: boolean + example: false + required: + - id + - name + - enabled + PlexSettings: + type: object + properties: + name: + type: string + example: 'Main Server' + readOnly: true + machineId: + type: string + example: '1234123412341234' + readOnly: true + ip: + type: string + example: '127.0.0.1' + port: + type: integer + example: 32400 + useSsl: + type: boolean + nullable: true + libraries: + type: array + readOnly: true + items: + $ref: '#/components/schemas/PlexLibrary' + webAppUrl: + type: string + nullable: true + example: 'https://app.plex.tv/desktop' + required: + - name + - machineId + - ip + - port + PlexConnection: + type: object + properties: + protocol: + type: string + example: 'https' + address: + type: string + example: '127.0.0.1' + port: + type: integer + example: 32400 + uri: + type: string + example: 'https://127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct:32400' + local: + type: boolean + example: true + status: + type: integer + example: 200 + message: + type: string + example: 'OK' + required: + - protocol + - address + - port + - uri + - local + PlexDevice: + type: object + properties: + name: + type: string + example: 'My Plex Server' + product: + type: string + example: 'Plex Media Server' + productVersion: + type: string + example: '1.21' + platform: + type: string + example: 'Linux' + platformVersion: + type: string + example: 'default/linux/amd64/17.1/systemd' + device: + type: string + example: 'PC' + clientIdentifier: + type: string + example: '85a943ce-a0cc-4d2a-a4ec-f74f06e40feb' + createdAt: + type: string + example: '2021-01-01T00:00:00.000Z' + lastSeenAt: + type: string + example: '2021-01-01T00:00:00.000Z' + provides: + type: array + items: + type: string + example: 'server' + owned: + type: boolean + example: true + ownerID: + type: string + example: '12345' + home: + type: boolean + example: true + sourceTitle: + type: string + example: 'xyzabc' + accessToken: + type: string + example: 'supersecretaccesstoken' + publicAddress: + type: string + example: '127.0.0.1' + httpsRequired: + type: boolean + example: true + synced: + type: boolean + example: true + relay: + type: boolean + example: true + dnsRebindingProtection: + type: boolean + example: false + natLoopbackSupported: + type: boolean + example: false + publicAddressMatches: + type: boolean + example: false + presence: + type: boolean + example: true + connection: + type: array + items: + $ref: '#/components/schemas/PlexConnection' + required: + - name + - product + - productVersion + - platform + - device + - clientIdentifier + - createdAt + - lastSeenAt + - provides + - owned + - connection + JellyfinLibrary: + type: object + properties: + id: + type: string + name: + type: string + example: Movies + enabled: + type: boolean + example: false + required: + - id + - name + - enabled + JellyfinSettings: + type: object + properties: + name: + type: string + example: 'Main Server' + readOnly: true + hostname: + type: string + example: 'http://my.jellyfin.host' + externalHostname: + type: string + example: 'http://my.jellyfin.host' + jellyfinForgotPasswordUrl: + type: string + example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html' + adminUser: + type: string + example: 'admin' + adminPass: + type: string + example: 'mypassword' + libraries: + type: array + readOnly: true + items: + $ref: '#/components/schemas/JellyfinLibrary' + serverID: + type: string + readOnly: true + MetadataSettings: + type: object + properties: + settings: + type: object + properties: + tv: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' + anime: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' + TautulliSettings: + type: object + properties: + hostname: + type: string + nullable: true + example: 'tautulli.example.com' + port: + type: integer + nullable: true + example: 8181 + useSsl: + type: boolean + nullable: true + apiKey: + type: string + nullable: true + externalUrl: + type: string + nullable: true + RadarrSettings: + type: object + properties: + id: + type: integer + example: 0 + readOnly: true + name: + type: string + example: 'Radarr Main' + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 7878 + apiKey: + type: string + example: 'exampleapikey' + useSsl: + type: boolean + example: false + baseUrl: + type: string + activeProfileId: + type: integer + example: 1 + activeProfileName: + type: string + example: 720p/1080p + activeDirectory: + type: string + example: '/movies' + is4k: + type: boolean + example: false + minimumAvailability: + type: string + example: 'In Cinema' + isDefault: + type: boolean + example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false + required: + - name + - hostname + - port + - apiKey + - useSsl + - activeProfileId + - activeProfileName + - activeDirectory + - is4k + - minimumAvailability + - isDefault + SonarrSettings: + type: object + properties: + id: + type: integer + example: 0 + readOnly: true + name: + type: string + example: 'Sonarr Main' + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 8989 + apiKey: + type: string + example: 'exampleapikey' + useSsl: + type: boolean + example: false + baseUrl: + type: string + activeProfileId: + type: integer + example: 1 + activeProfileName: + type: string + example: 720p/1080p + activeDirectory: + type: string + example: '/tv/' + activeLanguageProfileId: + type: integer + example: 1 + activeAnimeProfileId: + type: integer + nullable: true + activeAnimeLanguageProfileId: + type: integer + nullable: true + activeAnimeProfileName: + type: string + example: 720p/1080p + nullable: true + activeAnimeDirectory: + type: string + nullable: true + is4k: + type: boolean + example: false + enableSeasonFolders: + type: boolean + example: false + isDefault: + type: boolean + example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false + required: + - name + - hostname + - port + - apiKey + - useSsl + - activeProfileId + - activeProfileName + - activeDirectory + - is4k + - enableSeasonFolders + - isDefault + ServarrTag: + type: object + properties: + id: + type: integer + example: 1 + label: + type: string + example: A Label + PublicSettings: + type: object + properties: + initialized: + type: boolean + example: false + MovieResult: + type: object + required: + - id + - mediaType + # - title + properties: + id: + type: integer + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: integer + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + title: + type: string + example: Movie Title + originalTitle: + type: string + example: Original Movie Title + releaseDate: + type: string + adult: + type: boolean + example: false + video: + type: boolean + example: false + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + TvResult: + type: object + properties: + id: + type: integer + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: integer + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + name: + type: string + example: TV Show Name + originalName: + type: string + example: Original TV Show Name + originCountry: + type: array + items: + type: string + firstAirDate: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + PersonResult: + type: object + properties: + id: + type: integer + example: 12345 + profilePath: + type: string + adult: + type: boolean + example: false + mediaType: + type: string + default: 'person' + knownFor: + type: array + items: + oneOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + Genre: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: Adventure + Company: + type: object + properties: + id: + type: integer + example: 1 + logo_path: + type: string + nullable: true + name: + type: string + ProductionCompany: + type: object + properties: + id: + type: integer + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string + Network: + type: object + properties: + id: + type: integer + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string + RelatedVideo: + type: object + properties: + url: + type: string + example: https://www.youtube.com/watch?v=9qhL2_UxXM0/ + key: + type: string + example: 9qhL2_UxXM0 + name: + type: string + example: Trailer for some movie (1978) + size: + type: integer + example: 1080 + type: + type: string + example: Trailer + enum: + - Clip + - Teaser + - Trailer + - Featurette + - Opening Credits + - Behind the Scenes + - Bloopers + site: + type: string + enum: + - 'YouTube' + MovieDetails: + type: object + properties: + id: + type: integer + example: 123 + readOnly: true + imdbId: + type: string + example: 'tt123' + adult: + type: boolean + backdropPath: + type: string + posterPath: + type: string + budget: + type: integer + example: 1000000 + genres: + type: array + items: + $ref: '#/components/schemas/Genre' + homepage: + type: string + relatedVideos: + type: array + items: + $ref: '#/components/schemas/RelatedVideo' + originalLanguage: + type: string + originalTitle: + type: string + overview: + type: string + popularity: + type: number + productionCompanies: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + productionCountries: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + name: + type: string + releaseDate: + type: string + releases: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + nullable: true + release_dates: + type: array + items: + type: object + properties: + certification: + type: string + example: 'PG-13' + iso_639_1: + type: string + nullable: true + note: + type: string + nullable: true + example: 'Blu ray' + release_date: + type: string + example: '2017-07-12T00:00:00.000Z' + type: + type: integer + example: 1 + revenue: + type: number + nullable: true + runtime: + type: number + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' + status: + type: string + tagline: + type: string + title: + type: string + video: + type: boolean + voteAverage: + type: number + voteCount: + type: integer + credits: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + collection: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: A collection + posterPath: + type: string + backdropPath: + type: string + externalIds: + $ref: '#/components/schemas/ExternalIds' + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + watchProviders: + type: array + items: + $ref: '#/components/schemas/WatchProviders' + Episode: + type: object + properties: + id: + type: integer + name: + type: string + airDate: + type: string + nullable: true + episodeNumber: + type: integer + overview: + type: string + productionCode: + type: string + seasonNumber: + type: integer + showId: + type: integer + stillPath: + type: string + nullable: true + voteAverage: + type: number + voteCount: + type: integer + Season: + type: object + properties: + id: + type: integer + airDate: + type: string + nullable: true + episodeCount: + type: integer + name: + type: string + overview: + type: string + posterPath: + type: string + seasonNumber: + type: integer + episodes: + type: array + items: + $ref: '#/components/schemas/Episode' + TvDetails: + type: object + properties: + id: + type: integer + example: 123 + backdropPath: + type: string + posterPath: + type: string + contentRatings: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + example: 'TV-14' + createdBy: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + gender: + type: integer + profilePath: + type: string + nullable: true + episodeRunTime: + type: array + items: + type: integer + firstAirDate: + type: string + genres: + type: array + items: + $ref: '#/components/schemas/Genre' + homepage: + type: string + inProduction: + type: boolean + languages: + type: array + items: + type: string + lastAirDate: + type: string + lastEpisodeToAir: + $ref: '#/components/schemas/Episode' + name: + type: string + nextEpisodeToAir: + $ref: '#/components/schemas/Episode' + networks: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + numberOfEpisodes: + type: integer + numberOfSeason: + type: integer + originCountry: + type: array + items: + type: string + originalLanguage: + type: string + originalName: + type: string + overview: + type: string + popularity: + type: number + productionCompanies: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + productionCountries: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + name: + type: string + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' + seasons: + type: array + items: + $ref: '#/components/schemas/Season' + status: + type: string + tagline: + type: string + type: + type: string + voteAverage: + type: number + voteCount: + type: integer + credits: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + externalIds: + $ref: '#/components/schemas/ExternalIds' + keywords: + type: array + items: + $ref: '#/components/schemas/Keyword' + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + watchProviders: + type: array + items: + $ref: '#/components/schemas/WatchProviders' + MediaRequest: + type: object + properties: + id: + type: integer + example: 123 + readOnly: true + status: + type: integer + example: 0 + description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + readOnly: true + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' + modifiedBy: + anyOf: + - $ref: '#/components/schemas/User' + - type: string + nullable: true + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + type: + type: string + required: + - id + - status + MediaInfo: + type: object + properties: + id: + type: integer + readOnly: true + tmdbId: + type: integer + readOnly: true + tvdbId: + type: integer + readOnly: true + nullable: true + status: + type: integer + example: 0 + description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` + requests: + type: array + readOnly: true + items: + $ref: '#/components/schemas/MediaRequest' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + jellyfinMediaId: + type: string + jellyfinMediaId4k: + type: string + Cast: + type: object + properties: + id: + type: integer + example: 123 + castid: + type: integer + example: 1 + character: + type: string + example: Some Character Name + creditId: + type: string + gender: + type: integer + name: + type: string + example: Some Persons Name + order: + type: integer + profilePath: + type: string + nullable: true + Crew: + type: object + properties: + id: + type: integer + example: 123 + creditId: + type: string + gender: + type: integer + name: + type: string + example: Some Persons Name + job: + type: string + department: + type: string + profilePath: + type: string + nullable: true + ExternalIds: + type: object + properties: + facebookId: + type: string + nullable: true + freebaseId: + type: string + nullable: true + freebaseMid: + type: string + nullable: true + imdbId: + type: string + nullable: true + instagramId: + type: string + nullable: true + tvdbid: + type: integer + nullable: true + tvrageid: + type: integer + nullable: true + twitterId: + type: string + nullable: true + ServiceProfile: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: 720p/1080p + PageInfo: + type: object + properties: + page: + type: integer + example: 1 + pages: + type: integer + example: 10 + results: + type: integer + example: 100 + DiscordSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + botUsername: + type: string + botAvatarUrl: + type: string + webhookUrl: + type: string + webhookRoleId: + type: string + enableMentions: + type: boolean + SlackSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + WebPushSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + WebhookSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + authHeader: + type: string + jsonPayload: + type: string + supportVariables: + type: boolean + example: false + TelegramSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + botUsername: + type: string + botAPI: + type: string + chatId: + type: string + messageThreadId: + type: string + sendSilently: + type: boolean + PushbulletSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + accessToken: + type: string + channelTag: + type: string + nullable: true + PushoverSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + accessToken: + type: string + userToken: + type: string + sound: + type: string + GotifySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + url: + type: string + token: + type: string + NtfySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + url: + type: string + topic: + type: string + authMethodUsernamePassword: + type: boolean + username: + type: string + password: + type: string + authMethodToken: + type: boolean + token: + type: string + NotificationEmailSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + emailFrom: + type: string + example: no-reply@example.com + senderName: + type: string + example: Seerr + smtpHost: + type: string + example: 127.0.0.1 + smtpPort: + type: integer + example: 465 + secure: + type: boolean + example: false + ignoreTls: + type: boolean + example: false + requireTls: + type: boolean + example: false + authUser: + type: string + nullable: true + authPass: + type: string + nullable: true + allowSelfSigned: + type: boolean + example: false + Job: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + interval: + type: string + enum: [short, long, fixed] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + PersonDetails: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + deathday: + type: string + knownForDepartment: + type: string + alsoKnownAs: + type: array + items: + type: string + gender: + type: string + biography: + type: string + popularity: + type: string + placeOfBirth: + type: string + profilePath: + type: string + adult: + type: boolean + imdbId: + type: string + homepage: + type: string + CreditCast: + type: object + properties: + id: + type: integer + example: 1 + originalLanguage: + type: string + episodeCount: + type: integer + overview: + type: string + originCountry: + type: array + items: + type: string + originalName: + type: string + voteCount: + type: integer + name: + type: string + mediaType: + type: string + popularity: + type: number + creditId: + type: string + backdropPath: + type: string + firstAirDate: + type: string + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + posterPath: + type: string + profilePath: + type: string + nullable: true + originalTitle: + type: string + video: + type: boolean + title: + type: string + adult: + type: boolean + releaseDate: + type: string + character: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + CreditCrew: + type: object + properties: + id: + type: integer + example: 1 + originalLanguage: + type: string + episodeCount: + type: integer + overview: + type: string + originCountry: + type: array + items: + type: string + originalName: + type: string + voteCount: + type: integer + name: + type: string + mediaType: + type: string + popularity: + type: number + creditId: + type: string + backdropPath: + type: string + firstAirDate: + type: string + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + posterPath: + type: string + profilePath: + type: string + nullable: true + originalTitle: + type: string + video: + type: boolean + title: + type: string + adult: + type: boolean + releaseDate: + type: string + department: + type: string + job: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + Keyword: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: 'anime' + SpokenLanguage: + type: object + properties: + englishName: + type: string + example: 'English' + nullable: true + iso_639_1: + type: string + example: 'en' + name: + type: string + example: 'English' + Collection: + type: object + properties: + id: + type: integer + example: 123 + name: + type: string + example: A Movie Collection + overview: + type: string + example: Overview of collection + posterPath: + type: string + backdropPath: + type: string + parts: + type: array + items: + $ref: '#/components/schemas/MovieResult' + SonarrSeries: + type: object + properties: + title: + type: string + example: COVID-25 + sortTitle: + type: string + example: covid 25 + seasonCount: + type: integer + example: 1 + status: + type: string + example: upcoming + overview: + type: string + example: The thread is picked up again by Marianne Schmidt which ... + network: + type: string + example: CBS + airTime: + type: string + example: 02:15 + images: + type: array + items: + type: object + properties: + coverType: + type: string + example: banner + url: + type: string + example: /sonarr/MediaCoverProxy/6467f05d9872726ad08cbf920e5fee4bf69198682260acab8eab5d3c2c958e92/5c8f116c6aa5c.jpg + remotePoster: + type: string + example: https://artworks.thetvdb.com/banners/posters/5c8f116129983.jpg + seasons: + type: array + items: + type: object + properties: + seasonNumber: + type: integer + example: 1 + monitored: + type: boolean + example: true + year: + type: integer + example: 2015 + path: + type: string + profileid: + type: integer + languageProfileid: + type: integer + seasonFolder: + type: boolean + monitored: + type: boolean + useSceneNumbering: + type: boolean + runtime: + type: number + tvdbid: + type: integer + example: 12345 + tvRageid: + type: integer + tvMazeid: + type: integer + firstAired: + type: string + lastInfoSync: + type: string + nullable: true + seriesType: + type: string + cleanTitle: + type: string + imdbId: + type: string + titleSlug: + type: string + certification: + type: string + genres: + type: array + items: + type: string + tags: + type: array + items: + type: string + added: + type: string + ratings: + type: array + items: + type: object + properties: + votes: + type: integer + value: + type: integer + qualityProfileid: + type: integer + id: + type: integer + nullable: true + rootFolderPath: + type: string + nullable: true + addOptions: + type: array + items: + type: object + properties: + ignoreEpisodesWithFiles: + type: boolean + nullable: true + ignoreEpisodesWithoutFiles: + type: boolean + nullable: true + searchForMissingEpisodes: + type: boolean + nullable: true + UserSettingsNotifications: + type: object + properties: + notificationTypes: + $ref: '#/components/schemas/NotificationAgentTypes' + emailEnabled: + type: boolean + pgpKey: + type: string + nullable: true + discordEnabled: + type: boolean + discordEnabledTypes: + type: integer + nullable: true + discordId: + type: string + nullable: true + pushbulletAccessToken: + type: string + nullable: true + pushoverApplicationToken: + type: string + nullable: true + pushoverUserKey: + type: string + nullable: true + pushoverSound: + type: string + nullable: true + telegramEnabled: + type: boolean + telegramBotUsername: + type: string + nullable: true + telegramChatId: + type: string + nullable: true + telegramMessageThreadId: + type: string + nullable: true + telegramSendSilently: + type: boolean + nullable: true + NotificationAgentTypes: + type: object + properties: + discord: + type: integer + email: + type: integer + pushbullet: + type: integer + pushover: + type: integer + slack: + type: integer + telegram: + type: integer + webhook: + type: integer + webpush: + type: integer + WatchProviders: + type: object + properties: + iso_3166_1: + type: string + link: + type: string + buy: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + flatrate: + items: + $ref: '#/components/schemas/WatchProviderDetails' + WatchProviderDetails: + type: object + properties: + displayPriority: + type: integer + logoPath: + type: string + id: + type: integer + name: + type: string + Issue: + type: object + properties: + id: + type: integer + example: 1 + issueType: + type: integer + example: 1 + media: + $ref: '#/components/schemas/MediaInfo' + createdBy: + $ref: '#/components/schemas/User' + modifiedBy: + $ref: '#/components/schemas/User' + comments: + type: array + items: + $ref: '#/components/schemas/IssueComment' + IssueComment: + type: object + properties: + id: + type: integer + example: 1 + user: + $ref: '#/components/schemas/User' + message: + type: string + example: A comment + DiscoverSlider: + type: object + properties: + id: + type: integer + example: 1 + type: + type: integer + example: 1 + title: + type: string + nullable: true + isBuiltIn: + type: boolean + enabled: + type: boolean + data: + type: string + example: '1234' + nullable: true + required: + - type + - enabled + - title + - data + WatchProviderRegion: + type: object + properties: + iso_3166_1: + type: string + english_name: + type: string + native_name: + type: string + OverrideRule: + type: object + properties: + id: + type: string + Certification: + type: object + properties: + certification: + type: string + example: 'PG-13' + meaning: + type: string + example: 'Some material may be inappropriate for children under 13.' + nullable: true + order: + type: integer + example: 3 + nullable: true + required: + - certification + + CertificationResponse: + type: object + properties: + certifications: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/Certification' + example: + certifications: + US: + - certification: 'G' + meaning: 'All ages admitted' + order: 1 + - certification: 'PG' + meaning: 'Some material may not be suitable for children under 10.' + order: 2 + securitySchemes: + cookieAuth: + type: apiKey + name: connect.sid + in: cookie + apiKey: + type: apiKey + in: header + name: X-Api-Key + +paths: + /status: + get: + summary: Get Seerr status + description: Returns the current Seerr status in a JSON object. + security: [] + tags: + - public + responses: + '200': + description: Returned status + content: + application/json: + schema: + type: object + properties: + version: + type: string + example: 1.0.0 + commitTag: + type: string + updateAvailable: + type: boolean + commitsBehind: + type: integer + restartRequired: + type: boolean + /status/appdata: + get: + summary: Get application data volume status + description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs. + security: [] + tags: + - public + responses: + '200': + description: Application data volume status and path + content: + application/json: + schema: + type: object + properties: + appData: + type: boolean + example: true + appDataPath: + type: string + example: /app/config + appDataPermissions: + type: boolean + example: true + /settings/main: + get: + summary: Get main settings + description: Retrieves all main settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + post: + summary: Update main settings + description: Updates main settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + /settings/network: + get: + summary: Get network settings + description: Retrieves all network settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + post: + summary: Update network settings + description: Updates network settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NetworkSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NetworkSettings' + /settings/main/regenerate: + post: + summary: Get main settings with newly-generated API key + description: Returns main settings in a JSON object, using the new API key. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + /settings/jellyfin: + get: + summary: Get Jellyfin settings + description: Retrieves current Jellyfin settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + post: + summary: Update Jellyfin settings + description: Updates Jellyfin settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + /settings/jellyfin/library: + get: + summary: Get Jellyfin libraries + description: Returns a list of Jellyfin libraries in a JSON array. + tags: + - settings + parameters: + - in: query + name: sync + description: Syncs the current libraries with the current Jellyfin server + schema: + type: string + nullable: true + - in: query + name: enable + explode: false + allowReserved: true + description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + schema: + type: string + nullable: true + responses: + '200': + description: 'Jellyfin libraries returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + /settings/jellyfin/users: + get: + summary: Get Jellyfin Users + description: Returns a list of Jellyfin Users in a JSON array. + tags: + - settings + - users + responses: + '200': + description: Jellyfin users returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + username: + type: string + id: + type: string + thumb: + type: string + email: + type: string + /settings/jellyfin/sync: + get: + summary: Get status of full Jellyfin library sync + description: Returns sync progress in a JSON array. + tags: + - settings + responses: + '200': + description: Status of Jellyfin sync + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/JellyfinLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + post: + summary: Start full Jellyfin library sync + description: Runs a full Jellyfin library sync and returns the progress in a JSON array. + tags: + - settings + requestBody: + content: + application/json: + schema: + type: object + properties: + cancel: + type: boolean + example: false + start: + type: boolean + example: false + responses: + '200': + description: Status of Jellyfin sync + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/JellyfinLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + /settings/plex: + get: + summary: Get Plex settings + description: Retrieves current Plex settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + post: + summary: Update Plex settings + description: Updates Plex settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + /settings/plex/library: + get: + summary: Get Plex libraries + description: Returns a list of Plex libraries in a JSON array. + tags: + - settings + parameters: + - in: query + name: sync + description: Syncs the current libraries with the current Plex server + schema: + type: string + nullable: true + - in: query + name: enable + explode: false + allowReserved: true + description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + schema: + type: string + nullable: true + responses: + '200': + description: 'Plex libraries returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + /settings/plex/sync: + get: + summary: Get status of full Plex library scan + description: Returns scan progress in a JSON array. + tags: + - settings + responses: + '200': + description: Status of Plex scan + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/PlexLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + post: + summary: Start full Plex library scan + description: Runs a full Plex library scan and returns the progress in a JSON array. + tags: + - settings + requestBody: + content: + application/json: + schema: + type: object + properties: + cancel: + type: boolean + example: false + start: + type: boolean + example: false + responses: + '200': + description: Status of Plex scan + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/PlexLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + /settings/plex/devices/servers: + get: + summary: Gets the user's available Plex servers + description: Returns a list of available Plex servers and their connectivity state + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexDevice' + /settings/plex/users: + get: + summary: Get Plex users + description: | + Returns a list of Plex users in a JSON array. + + Requires the `MANAGE_USERS` permission. + tags: + - settings + - users + responses: + '200': + description: Plex users + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + username: + type: string + email: + type: string + thumb: + type: string + /settings/metadatas: + get: + summary: Get Metadata settings + description: Retrieves current Metadata settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + put: + summary: Update Metadata settings + description: Updates Metadata settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + /settings/metadatas/test: + post: + summary: Test Provider configuration + description: Tests if the TVDB configuration is valid. Returns a list of available languages on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tmdb: + type: boolean + example: true + tvdb: + type: boolean + example: true + responses: + '200': + description: Succesfully connected to TVDB + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Successfully connected to TVDB' + /settings/tautulli: + get: + summary: Get Tautulli settings + description: Retrieves current Tautulli settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + post: + summary: Update Tautulli settings + description: Updates Tautulli settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + /settings/radarr: + get: + summary: Get Radarr settings + description: Returns all Radarr settings in a JSON array. + tags: + - settings + responses: + '200': + description: 'Values were returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + post: + summary: Create Radarr instance + description: Creates a new Radarr instance from the request body. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + responses: + '201': + description: 'New Radarr instance created' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + /settings/radarr/test: + post: + summary: Test Radarr configuration + description: Tests if the Radarr configuration is valid. Returns profiles and root folders on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 7878 + apiKey: + type: string + example: yourapikey + useSsl: + type: boolean + example: false + baseUrl: + type: string + required: + - hostname + - port + - apiKey + - useSsl + responses: + '200': + description: Succesfully connected to Radarr instance + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/radarr/{radarrId}: + put: + summary: Update Radarr instance + description: Updates an existing Radarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + delete: + summary: Delete Radarr instance + description: Deletes an existing Radarr instance based on the radarrId parameter. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + /settings/radarr/{radarrId}/profiles: + get: + summary: Get available Radarr profiles + description: Returns a list of profiles available on the Radarr server instance in a JSON array. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + responses: + '200': + description: Returned list of profiles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/sonarr: + get: + summary: Get Sonarr settings + description: Returns all Sonarr settings in a JSON array. + tags: + - settings + responses: + '200': + description: 'Values were returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + post: + summary: Create Sonarr instance + description: Creates a new Sonarr instance from the request body. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + responses: + '201': + description: 'New Sonarr instance created' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + /settings/sonarr/test: + post: + summary: Test Sonarr configuration + description: Tests if the Sonarr configuration is valid. Returns profiles and root folders on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 8989 + apiKey: + type: string + example: yourapikey + useSsl: + type: boolean + example: false + baseUrl: + type: string + required: + - hostname + - port + - apiKey + - useSsl + responses: + '200': + description: Succesfully connected to Sonarr instance + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/sonarr/{sonarrId}: + put: + summary: Update Sonarr instance + description: Updates an existing Sonarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + description: Sonarr instance ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + responses: + '200': + description: 'Sonarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + delete: + summary: Delete Sonarr instance + description: Deletes an existing Sonarr instance based on the sonarrId parameter. + tags: + - settings + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + description: Sonarr instance ID + responses: + '200': + description: 'Sonarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + /settings/public: + get: + summary: Get public settings + security: [] + description: Returns settings that are not protected or sensitive. Mainly used to determine if the application has been configured for the first time. + tags: + - settings + responses: + '200': + description: Public settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/PublicSettings' + /settings/initialize: + post: + summary: Initialize application + description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. + tags: + - settings + responses: + '200': + description: Public settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/PublicSettings' + /settings/jobs: + get: + summary: Get scheduled jobs + description: Returns list of all scheduled jobs and details about their next execution time in a JSON array. + tags: + - settings + responses: + '200': + description: Scheduled jobs returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/run: + post: + summary: Invoke a specific job + description: Invokes a specific job to run. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Invoked job returned + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/cancel: + post: + summary: Cancel a specific job + description: Cancels a specific job. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Canceled job returned + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/schedule: + post: + summary: Modify job schedule + description: Re-registers the job with the schedule specified. Will return the job in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + schedule: + type: string + example: '0 */5 * * * *' + responses: + '200': + description: Rescheduled job + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/cache: + get: + summary: Get a list of active caches + description: Retrieves a list of all active caches and their current stats. + tags: + - settings + responses: + '200': + description: Caches returned + content: + application/json: + schema: + type: object + properties: + imageCache: + type: object + properties: + tmdb: + type: object + properties: + size: + type: integer + example: 123456 + imageCount: + type: integer + example: 123 + avatar: + type: object + properties: + size: + type: integer + example: 123456 + imageCount: + type: integer + example: 123 + dnsCache: + type: object + properties: + stats: + type: object + properties: + size: + type: integer + example: 1 + maxSize: + type: integer + example: 500 + hits: + type: integer + example: 19 + misses: + type: integer + example: 1 + failures: + type: integer + example: 0 + ipv4Fallbacks: + type: integer + example: 0 + hitRate: + type: integer + example: 0.95 + entries: + type: array + additionalProperties: + type: object + properties: + addresses: + type: object + properties: + ipv4: + type: integer + example: 1 + ipv6: + type: integer + example: 1 + activeAddress: + type: string + example: 127.0.0.1 + family: + type: integer + example: 4 + age: + type: integer + example: 10 + ttl: + type: integer + example: 10 + networkErrors: + type: integer + example: 0 + hits: + type: integer + example: 1 + misses: + type: integer + example: 1 + apiCaches: + type: array + items: + type: object + properties: + id: + type: string + example: cache-id + name: + type: string + example: cache name + stats: + type: object + properties: + hits: + type: integer + misses: + type: integer + keys: + type: integer + ksize: + type: integer + vsize: + type: integer + /settings/cache/{cacheId}/flush: + post: + summary: Flush a specific cache + description: Flushes all data from the cache ID provided + tags: + - settings + parameters: + - in: path + name: cacheId + required: true + schema: + type: string + responses: + '204': + description: 'Flushed cache' + /settings/cache/dns/{dnsEntry}/flush: + post: + summary: Flush a specific DNS cache entry + description: Flushes a specific DNS cache entry + tags: + - settings + parameters: + - in: path + name: dnsEntry + required: true + schema: + type: string + responses: + '204': + description: 'Flushed dns cache' + /settings/logs: + get: + summary: Returns logs + description: Returns list of all log items and details + tags: + - settings + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 25 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: [debug, info, warn, error] + default: debug + - in: query + name: search + schema: + type: string + nullable: true + example: plex + responses: + '200': + description: Server log returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + label: + type: string + example: server + level: + type: string + example: info + message: + type: string + example: Server ready on port 5055 + timestamp: + type: string + example: '2020-12-15T16:20:00.069Z' + /settings/notifications/email: + get: + summary: Get email notification settings + description: Returns current email notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned email settings + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + post: + summary: Update email notification settings + description: Updates email notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + /settings/notifications/email/test: + post: + summary: Test email settings + description: Sends a test notification to the email agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/discord: + get: + summary: Get Discord notification settings + description: Returns current Discord notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Discord settings + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + post: + summary: Update Discord notification settings + description: Updates Discord notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + /settings/notifications/discord/test: + post: + summary: Test Discord settings + description: Sends a test notification to the Discord agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushbullet: + get: + summary: Get Pushbullet notification settings + description: Returns current Pushbullet notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Pushbullet settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + post: + summary: Update Pushbullet notification settings + description: Update Pushbullet notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + /settings/notifications/pushbullet/test: + post: + summary: Test Pushbullet settings + description: Sends a test notification to the Pushbullet agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushover: + get: + summary: Get Pushover notification settings + description: Returns current Pushover notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Pushover settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + post: + summary: Update Pushover notification settings + description: Update Pushover notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + /settings/notifications/pushover/test: + post: + summary: Test Pushover settings + description: Sends a test notification to the Pushover agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushover/sounds: + get: + summary: Get Pushover sounds + description: Returns valid Pushover sound options in a JSON array. + tags: + - settings + parameters: + - in: query + name: token + required: true + schema: + type: string + nullable: false + responses: + '200': + description: Returned Pushover settings + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + /settings/notifications/gotify: + get: + summary: Get Gotify notification settings + description: Returns current Gotify notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Gotify settings + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + post: + summary: Update Gotify notification settings + description: Update Gotify notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + /settings/notifications/gotify/test: + post: + summary: Test Gotify settings + description: Sends a test notification to the Gotify agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/ntfy: + get: + summary: Get ntfy.sh notification settings + description: Returns current ntfy.sh notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned ntfy.sh settings + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + post: + summary: Update ntfy.sh notification settings + description: Update ntfy.sh notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + /settings/notifications/ntfy/test: + post: + summary: Test ntfy.sh settings + description: Sends a test notification to the ntfy.sh agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/slack: + get: + summary: Get Slack notification settings + description: Returns current Slack notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned slack settings + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + post: + summary: Update Slack notification settings + description: Updates Slack notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + /settings/notifications/slack/test: + post: + summary: Test Slack settings + description: Sends a test notification to the Slack agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/telegram: + get: + summary: Get Telegram notification settings + description: Returns current Telegram notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Telegram settings + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + post: + summary: Update Telegram notification settings + description: Update Telegram notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + /settings/notifications/telegram/test: + post: + summary: Test Telegram settings + description: Sends a test notification to the Telegram agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/webpush: + get: + summary: Get Web Push notification settings + description: Returns current Web Push notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned web push settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + post: + summary: Update Web Push notification settings + description: Updates Web Push notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + /settings/notifications/webpush/test: + post: + summary: Test Web Push settings + description: Sends a test notification to the Web Push agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/webhook: + get: + summary: Get webhook notification settings + description: Returns current webhook notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned webhook settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + post: + summary: Update webhook notification settings + description: Updates webhook notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + /settings/notifications/webhook/test: + post: + summary: Test webhook settings + description: Sends a test notification to the webhook agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '204': + description: Test notification attempted + /settings/discover: + get: + summary: Get all discover sliders + description: Returns all discovery sliders. Built-in and custom made. + tags: + - settings + responses: + '200': + description: Returned all discovery sliders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + post: + summary: Batch update all sliders. + description: | + Batch update all sliders at once. Should also be used for creation. Will only update sliders provided + and will not delete any sliders not present in the request. If a slider is missing a required field, + it will be ignored. Requires the `ADMIN` permission. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + responses: + '200': + description: Returned all newly updated discovery sliders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/{sliderId}: + put: + summary: Update a single slider + description: | + Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission. + tags: + - settings + parameters: + - in: path + name: sliderId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: 'Slider Title' + type: + type: integer + example: 1 + data: + type: string + example: '1' + responses: + '200': + description: Returns newly added discovery slider + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + delete: + summary: Delete slider by ID + description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission. + tags: + - settings + parameters: + - in: path + name: sliderId + required: true + schema: + type: integer + responses: + '200': + description: Slider successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/add: + post: + summary: Add a new slider + description: | + Add a single slider and return the newly created slider. Requires the `ADMIN` permission. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: 'New Slider' + type: + type: integer + example: 1 + data: + type: string + example: '1' + responses: + '200': + description: Returns newly added discovery slider + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/reset: + get: + summary: Reset all discover sliders + description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission. + tags: + - settings + responses: + '204': + description: All sliders reset to defaults + /settings/about: + get: + summary: Get server stats + description: Returns current server stats in a JSON object. + tags: + - settings + responses: + '200': + description: Returned about settings + content: + application/json: + schema: + type: object + properties: + version: + type: string + example: '1.0.0' + totalRequests: + type: integer + example: 100 + totalMediaItems: + type: integer + example: 100 + tz: + type: string + nullable: true + example: Asia/Tokyo + appDataPath: + type: string + example: /app/config + /auth/me: + get: + summary: Get logged-in user + description: Returns the currently logged-in user. + tags: + - auth + - users + responses: + '200': + description: Object containing the logged-in user in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /auth/plex: + post: + summary: Sign in using a Plex token + description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + /auth/jellyfin: + post: + summary: Sign in using a Jellyfin username and password + description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the Jellyfin server, they will also have an account created, but without any permissions. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + hostname: + type: string + email: + type: string + serverType: + type: integer + required: + - username + - password + /auth/local: + post: + summary: Sign in using a local account + description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + required: + - email + - password + /auth/logout: + post: + summary: Sign out and clear session cookie + description: Completely clear the session cookie and associated values, effectively signing the user out. + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + /auth/reset-password: + post: + summary: Send a reset password email + description: Sends a reset password email to the email if the user exists + security: [] + tags: + - users + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + required: + - email + /auth/reset-password/{guid}: + post: + summary: Reset the password for a user + description: Resets the password for a user if the given guid is connected to a user + security: [] + tags: + - users + parameters: + - in: path + name: guid + required: true + schema: + type: string + example: '9afef5a7-ec89-4d5f-9397-261e96970b50' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + type: string + required: + - password + /user: + get: + summary: Get all users + description: Returns all users in a JSON object. + tags: + - users + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [created, updated, requests, displayname] + default: created + - in: query + name: q + required: false + schema: + type: string + - in: query + name: includeIds + required: false + schema: + type: string + responses: + '200': + description: A JSON array of all users + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/User' + post: + summary: Create new user + description: | + Creates a new user. Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + example: 'hey@itsme.com' + username: + type: string + permissions: + type: integer + responses: + '201': + description: The created user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update batch of users + description: | + Update users with given IDs with provided values in request `body.settings`. You cannot update users' Plex tokens through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: + type: integer + permissions: + type: integer + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/import-from-plex: + post: + summary: Import all users from Plex + description: | + Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + plexIds: + type: array + items: + type: string + responses: + '201': + description: A list of the newly created users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/import-from-jellyfin: + post: + summary: Import all users from Jellyfin + description: | + Fetches and imports users from the Jellyfin server. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + jellyfinUserIds: + type: array + items: + type: string + responses: + '201': + description: A list of the newly created users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/registerPushSubscription: + post: + summary: Register a web push /user/registerPushSubscription + description: Registers a web push subscription for the logged-in user + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + auth: + type: string + p256dh: + type: string + userAgent: + type: string + required: + - endpoint + - auth + - p256dh + responses: + '204': + description: Successfully registered push subscription + /user/{userId}/pushSubscriptions: + get: + summary: Get all web push notification settings for a user + description: | + Returns all web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + /user/{userId}/pushSubscription/{endpoint}: + get: + summary: Get web push notification settings for a user + description: | + Returns web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: path + name: endpoint + required: true + schema: + type: string + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + delete: + summary: Delete user push subscription by key + description: Deletes the user push subscription with the provided key. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: path + name: endpoint + required: true + schema: + type: string + responses: + '204': + description: Successfully removed user push subscription + /user/{userId}: + get: + summary: Get user by ID + description: | + Retrieves user details in a JSON object. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: Users details in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update a user by user ID + description: | + Update a user with the provided values. You cannot update a user's Plex token through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + $ref: '#/components/schemas/User' + delete: + summary: Delete user by ID + description: Deletes the user with the provided userId. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /user/{userId}/requests: + get: + summary: Get requests for a specific user + description: | + Retrieves a user's requests in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + responses: + '200': + description: User's requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + /user/{userId}/quota: + get: + summary: Get quotas for a specific user + description: | + Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User quota details in JSON + content: + application/json: + schema: + type: object + properties: + movie: + type: object + properties: + days: + type: integer + example: 7 + limit: + type: integer + example: 10 + used: + type: integer + example: 6 + remaining: + type: integer + example: 4 + restricted: + type: boolean + example: false + tv: + type: object + properties: + days: + type: integer + example: 7 + limit: + type: integer + example: 10 + used: + type: integer + example: 6 + remaining: + type: integer + example: 4 + restricted: + type: boolean + example: false + /blacklist: + get: + summary: Returns blacklisted items + description: Returns list of all blacklisted media + tags: + - settings + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 25 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: search + schema: + type: string + nullable: true + example: dune + - in: query + name: filter + schema: + type: string + enum: [all, manual, blacklistedTags] + default: manual + responses: + '200': + description: Blacklisted items returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + type: object + properties: + user: + $ref: '#/components/schemas/User' + createdAt: + type: string + example: 2024-04-21T01:55:44.000Z + id: + type: integer + example: 1 + mediaType: + type: string + example: movie + title: + type: string + example: Dune + tmdbid: + type: integer + example: 438631 + post: + summary: Add media to blacklist + tags: + - blacklist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Blacklist' + responses: + '201': + description: Item succesfully blacklisted + '412': + description: Item has already been blacklisted + /blacklist/{tmdbId}: + get: + summary: Get media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Blacklist details in JSON + delete: + summary: Remove media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item + /watchlist: + post: + summary: Add media to watchlist + tags: + - watchlist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + /watchlist/{tmdbId}: + delete: + summary: Delete watchlist item + description: Removes a watchlist item. + tags: + - watchlist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed watchlist item + /user/{userId}/watchlist: + get: + summary: Get the Plex watchlist for a specific user + description: | + Retrieves a user's Plex Watchlist in a JSON object. + tags: + - users + - watchlist + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: object + properties: + page: + type: integer + totalPages: + type: integer + totalResults: + type: integer + results: + type: array + items: + type: object + properties: + tmdbid: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + /user/{userId}/settings/main: + get: + summary: Get general settings for a user + description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User general settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + post: + summary: Update general settings for a user + description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + /user/{userId}/settings/password: + get: + summary: Get password page informatiom + description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User password page information returned + content: + application/json: + schema: + type: object + properties: + hasPassword: + type: boolean + example: true + post: + summary: Update password for a user + description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + currentPassword: + type: string + nullable: true + newPassword: + type: string + required: + - newPassword + responses: + '204': + description: User password updated + /user/{userId}/settings/linked-accounts/plex: + post: + summary: Link the provided Plex account to the current user + description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Plex account for a user + description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/linked-accounts/jellyfin: + post: + summary: Link the provided Jellyfin account to the current user + description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + password: + type: string + example: 'supersecret' + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Jellyfin account for a user + description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/notifications: + get: + summary: Get notification settings for a user + description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + post: + summary: Update notification settings for a user + description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + responses: + '200': + description: Updated user notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + /user/{userId}/settings/permissions: + get: + summary: Get permission settings for a user + description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User permission settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + example: 2 + post: + summary: Update permission settings for a user + description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + required: + - permissions + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + example: 2 + /user/{userId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and recently watched media. + + Requires the `ADMIN` permission to fetch results for other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + recentlyWatched: + type: array + items: + $ref: '#/components/schemas/MediaInfo' + playCount: + type: integer + /search: + get: + summary: Search for movies, TV shows, or people + description: Returns a list of movies, TV shows, or people a JSON object. + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'Mulan' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + - $ref: '#/components/schemas/PersonResult' + /search/keyword: + get: + summary: Search for keywords + description: Returns a list of TMDB keywords matching the search query + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'christmas' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/Keyword' + /search/company: + get: + summary: Search for companies + description: Returns a list of TMDB companies matching the search query. (Will not return origin country) + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'Disney' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/Company' + /discover/movies: + get: + summary: Discover movies + description: Returns a list of movies in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: studio + schema: + type: integer + example: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: excludeKeywords + schema: + type: string + example: 3,4 + description: Comma-separated list of keyword IDs to exclude from results + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: primaryReleaseDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: primaryReleaseDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: integer + example: 60 + - in: query + name: withRuntimeLte + schema: + type: integer + example: 120 + - in: query + name: voteAverageGte + schema: + type: integer + example: 7 + - in: query + name: voteAverageLte + schema: + type: integer + example: 10 + - in: query + name: voteCountGte + schema: + type: integer + example: 7 + - in: query + name: voteCountLte + schema: + type: integer + example: 10 + - in: query + name: watchRegion + schema: + type: string + example: US + - in: query + name: watchProviders + schema: + type: string + example: 8|9 + - in: query + name: certification + schema: + type: string + example: PG-13 + description: Exact certification to filter by (used when certificationMode is 'exact') + - in: query + name: certificationGte + schema: + type: string + example: G + description: Minimum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationLte + schema: + type: string + example: PG-13 + description: Maximum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationCountry + schema: + type: string + example: US + description: Country code for the certification system (e.g., US, GB, CA) + - in: query + name: certificationMode + schema: + type: string + enum: [exact, range] + example: exact + description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/genre/{genreId}: + get: + summary: Discover movies by genre + description: Returns a list of movies based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/language/{language}: + get: + summary: Discover movies by original language + description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/studio/{studioId}: + get: + summary: Discover movies by studio + description: Returns a list of movies based on the provided studio ID in a JSON object. + tags: + - search + parameters: + - in: path + name: studioId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + studio: + $ref: '#/components/schemas/ProductionCompany' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/upcoming: + get: + summary: Upcoming movies + description: Returns a list of movies in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/tv: + get: + summary: Discover TV shows + description: Returns a list of TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: network + schema: + type: integer + example: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: excludeKeywords + schema: + type: string + example: 3,4 + description: Comma-separated list of keyword IDs to exclude from results + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: firstAirDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: firstAirDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: integer + example: 60 + - in: query + name: withRuntimeLte + schema: + type: integer + example: 120 + - in: query + name: voteAverageGte + schema: + type: integer + example: 7 + - in: query + name: voteAverageLte + schema: + type: integer + example: 10 + - in: query + name: voteCountGte + schema: + type: integer + example: 7 + - in: query + name: voteCountLte + schema: + type: integer + example: 10 + - in: query + name: watchRegion + schema: + type: string + example: US + - in: query + name: watchProviders + schema: + type: string + example: 8|9 + - in: query + name: status + schema: + type: string + example: 3|4 + - in: query + name: certification + schema: + type: string + example: TV-14 + description: Exact certification to filter by (used when certificationMode is 'exact') + - in: query + name: certificationGte + schema: + type: string + example: TV-PG + description: Minimum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationLte + schema: + type: string + example: TV-MA + description: Maximum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationCountry + schema: + type: string + example: US + description: Country code for the certification system (e.g., US, GB, CA) + - in: query + name: certificationMode + schema: + type: string + enum: [exact, range] + example: exact + description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/language/{language}: + get: + summary: Discover TV shows by original language + description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/genre/{genreId}: + get: + summary: Discover TV shows by genre + description: Returns a list of TV shows based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/network/{networkId}: + get: + summary: Discover TV shows by network + description: Returns a list of TV shows based on the provided network ID in a JSON object. + tags: + - search + parameters: + - in: path + name: networkId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + network: + $ref: '#/components/schemas/Network' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/upcoming: + get: + summary: Discover Upcoming TV shows + description: Returns a list of upcoming TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/trending: + get: + summary: Trending movies and TV + description: Returns a list of movies and TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + - $ref: '#/components/schemas/PersonResult' + /discover/keyword/{keywordId}/movies: + get: + summary: Get movies from keyword + description: Returns list of movies based on the provided keyword ID a JSON object. + tags: + - search + parameters: + - in: path + name: keywordId + required: true + schema: + type: integer + example: 207317 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/genreslider/movie: + get: + summary: Get genre slider data for movies + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/genreslider/tv: + get: + summary: Get genre slider data for TV series + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/watchlist: + get: + summary: Get the Plex watchlist. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: object + properties: + page: + type: integer + totalPages: + type: integer + totalResults: + type: integer + results: + type: array + items: + type: object + properties: + tmdbid: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + /request: + get: + summary: Get all requests + description: | + Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. + + If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned. + tags: + - request + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: + [ + all, + approved, + available, + pending, + processing, + unavailable, + failed, + deleted, + completed, + ] + - in: query + name: sort + schema: + type: string + enum: [added, modified] + default: added + - in: query + name: sortDirection + schema: + type: string + enum: [asc, desc] + nullable: true + default: desc + - in: query + name: requestedBy + schema: + type: integer + nullable: true + example: 1 + - in: query + name: mediaType + schema: + type: string + enum: [movie, tv, all] + nullable: true + default: all + responses: + '200': + description: Requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + post: + summary: Create new request + description: | + Creates a new request with the provided media ID and type. The `REQUEST` permission is required. + + If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved. + tags: + - request + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + example: movie + mediaId: + type: integer + example: 123 + tvdbid: + type: integer + example: 123 + seasons: + # oneOf: + # - type: array + # items: + # type: integer + # minimum: 0 + # - type: string + # enum: [all] + # TODO + type: string + enum: [all] + nullable: true + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + nullable: true + languageProfileid: + type: integer + userid: + type: integer + nullable: true + required: + - mediaType + - mediaId + responses: + '201': + description: Succesfully created the request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/count: + get: + summary: Gets request counts + description: | + Returns the number of requests by status including pending, approved, available, and completed requests. + tags: + - request + responses: + '200': + description: Request counts returned + content: + application/json: + schema: + type: object + properties: + total: + type: integer + movie: + type: integer + tv: + type: integer + pending: + type: integer + approved: + type: integer + declined: + type: integer + processing: + type: integer + available: + type: integer + completed: + type: integer + /request/{requestId}: + get: + summary: Get MediaRequest + description: Returns a specific MediaRequest in a JSON object. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Succesfully returns request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + put: + summary: Update MediaRequest + description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + seasons: + type: array + items: + type: integer + minimum: 0 + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + languageProfileid: + type: integer + userid: + type: integer + nullable: true + required: + - mediaType + responses: + '200': + description: Succesfully updated request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + delete: + summary: Delete request + description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, any request can be removed. Otherwise, only pending requests can be removed. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed request + /request/{requestId}/retry: + post: + summary: Retry failed request + description: | + Retries a request by resending requests to Sonarr or Radarr. + + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: '1' + responses: + '200': + description: Retry triggered + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/{requestId}/{status}: + post: + summary: Update a request's status + description: | + Updates a request's status to approved or declined. Also returns the request in a JSON object. + + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: '1' + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [approve, decline] + responses: + '200': + description: Request status changed + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /movie/{movieId}: + get: + summary: Get movie details + description: Returns full movie details in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Movie details + content: + application/json: + schema: + $ref: '#/components/schemas/MovieDetails' + /movie/{movieId}/recommendations: + get: + summary: Get recommended movies + description: Returns list of recommended movies based on provided movie ID in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /movie/{movieId}/similar: + get: + summary: Get similar movies + description: Returns list of similar movies based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /movie/{movieId}/ratings: + get: + summary: Get movie ratings + description: Returns ratings based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: Mulan + year: + type: integer + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + /movie/{movieId}/ratingscombined: + get: + summary: Get RT and IMDB movie ratings combined + description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + rt: + type: object + properties: + title: + type: string + example: Mulan + year: + type: integer + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + imdb: + type: object + properties: + title: + type: string + example: I am Legend + url: + type: string + example: 'https://www.imdb.com/title/tt0480249' + criticsScore: + type: integer + example: 6.5 + /tv/{tvId}: + get: + summary: Get TV details + description: Returns full TV details in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: TV details + content: + application/json: + schema: + $ref: '#/components/schemas/TvDetails' + /tv/{tvId}/season/{seasonNumber}: + get: + summary: Get season details and episode list + description: Returns season details with a list of episodes in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: path + name: seasonNumber + required: true + schema: + type: integer + example: 123456 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: TV details + content: + application/json: + schema: + $ref: '#/components/schemas/Season' + /tv/{tvId}/recommendations: + get: + summary: Get recommended TV series + description: Returns list of recommended TV series based on the provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of TV series + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /tv/{tvId}/similar: + get: + summary: Get similar TV series + description: Returns list of similar TV series based on the provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of TV series + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /tv/{tvId}/ratings: + get: + summary: Get TV ratings + description: Returns ratings based on provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: The Boys + year: + type: integer + example: 2019 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh'] + /person/{personId}: + get: + summary: Get person details + description: Returns person details based on provided personId in a JSON object. + tags: + - person + parameters: + - in: path + name: personId + required: true + schema: + type: integer + example: 287 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Returned person + content: + application/json: + schema: + $ref: '#/components/schemas/PersonDetails' + /person/{personId}/combined_credits: + get: + summary: Get combined credits + description: Returns the person's combined credits based on the provided personId in a JSON object. + tags: + - person + parameters: + - in: path + name: personId + required: true + schema: + type: integer + example: 287 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Returned combined credits + content: + application/json: + schema: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + id: + type: integer + /media: + get: + summary: Get media + description: Returns all media (can be filtered and limited) in a JSON object. + tags: + - media + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: + [ + all, + available, + partial, + allavailable, + processing, + pending, + deleted, + ] + - in: query + name: sort + schema: + type: string + enum: [added, modified, mediaAdded] + default: added + responses: + '200': + description: Returned media + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaInfo' + /media/{mediaId}: + delete: + summary: Delete media item + description: Removes a media item. The `MANAGE_REQUESTS` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item + /media/{mediaId}/file: + delete: + summary: Delete media file + description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + - in: query + name: is4k + description: Whether to remove from 4K service instance (true) or regular service instance (false) + required: false + example: false + schema: + type: boolean + responses: + '204': + description: Successfully removed media item + /media/{mediaId}/{status}: + post: + summary: Update media status + description: Updates a media item's status and returns the media in JSON format + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + - in: path + name: status + description: New status + required: true + example: available + schema: + type: string + enum: [available, partial, processing, pending, unknown, deleted] + requestBody: + content: + application/json: + schema: + type: object + properties: + is4k: + type: boolean + example: false + description: | + When true, updates the 4K status field (status4k). + When false or not provided, updates the regular status field (status). + This applies to all status values (available, partial, processing, pending, unknown). + responses: + '200': + description: Returned media + content: + application/json: + schema: + $ref: '#/components/schemas/MediaInfo' + /media/{mediaId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and users who have watched the media. + + Requires the `ADMIN` permission. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + playCount7Days: + type: integer + playCount30Days: + type: integer + playCount: + type: integer + users: + type: array + items: + $ref: '#/components/schemas/User' + data4k: + type: object + properties: + playCount7Days: + type: integer + playCount30Days: + type: integer + playCount: + type: integer + users: + type: array + items: + $ref: '#/components/schemas/User' + /collection/{collectionId}: + get: + summary: Get collection details + description: Returns full collection details in a JSON object. + tags: + - collection + parameters: + - in: path + name: collectionId + required: true + schema: + type: integer + example: 537982 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Collection details + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' + /service/radarr: + get: + summary: Get non-sensitive Radarr server list + description: Returns a list of Radarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + /service/radarr/{radarrId}: + get: + summary: Get Radarr server quality profiles and root folders + description: Returns a Radarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/RadarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr: + get: + summary: Get non-sensitive Sonarr server list + description: Returns a list of Sonarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + /service/sonarr/{sonarrId}: + get: + summary: Get Sonarr server quality profiles and root folders + description: Returns a Sonarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/SonarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr/lookup/{tmdbId}: + get: + summary: Get series from Sonarr + description: Returns a list of series returned by searching for the name in Sonarr. + tags: + - service + parameters: + - in: path + name: tmdbId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSeries' + /regions: + get: + summary: Regions supported by TMDB + description: Returns a list of regions in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America + /languages: + get: + summary: Languages supported by TMDB + description: Returns a list of languages in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English + /studio/{studioId}: + get: + summary: Get movie studio details + description: Returns movie studio details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: studioId + required: true + schema: + type: integer + example: 2 + responses: + '200': + description: Movie studio details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /network/{networkId}: + get: + summary: Get TV network details + description: Returns TV network details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: networkId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: TV network details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /genres/movie: + get: + summary: Get list of official TMDB movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 10751 + name: + type: string + example: Family + /genres/tv: + get: + summary: Get list of official TMDB movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 18 + name: + type: string + example: Drama + /backdrops: + get: + summary: Get backdrops of trending items + description: Returns a list of backdrop image paths in a JSON array. + security: [] + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: string + /issue: + get: + summary: Get all issues + description: | + Returns a list of issues in JSON format. + tags: + - issue + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [added, modified] + default: added + - in: query + name: filter + schema: + type: string + enum: [all, open, resolved] + default: open + - in: query + name: requestedBy + schema: + type: integer + nullable: true + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/Issue' + post: + summary: Create new issue + description: | + Creates a new issue + tags: + - issue + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + issueType: + type: integer + message: + type: string + mediaid: + type: integer + responses: + '201': + description: Succesfully created the issue + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + + /issue/count: + get: + summary: Gets issue counts + description: | + Returns the number of open and closed issues, as well as the number of issues of each type. + tags: + - issue + responses: + '200': + description: Issue counts returned + content: + application/json: + schema: + type: object + properties: + total: + type: integer + video: + type: integer + audio: + type: integer + subtitles: + type: integer + others: + type: integer + open: + type: integer + closed: + type: integer + /issue/{issueId}: + get: + summary: Get issue + description: | + Returns a single issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + delete: + summary: Delete issue + description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue + /issue/{issueId}/comment: + post: + summary: Create a comment + description: | + Creates a comment and returns associated issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: integer + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: + - message + responses: + '200': + description: Issue returned with new comment + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /issueComment/{commentId}: + get: + summary: Get issue comment + description: | + Returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + responses: + '200': + description: Comment returned + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + put: + summary: Update issue comment + description: | + Updates and returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + responses: + '200': + description: Comment updated + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + delete: + summary: Delete issue comment + description: | + Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action. + tags: + - issue + parameters: + - in: path + name: commentId + description: Issue Comment ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue comment + /issue/{issueId}/{status}: + post: + summary: Update an issue's status + description: | + Updates an issue's status to approved or declined. Also returns the issue in a JSON object. + + Requires the `MANAGE_ISSUES` permission or `ADMIN`. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + schema: + type: string + example: '1' + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [open, resolved] + responses: + '200': + description: Issue status changed + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /keyword/{keywordId}: + get: + summary: Get keyword + description: | + Returns a single keyword in JSON format. + tags: + - other + parameters: + - in: path + name: keywordId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: Keyword returned (null if not found) + content: + application/json: + schema: + nullable: true + $ref: '#/components/schemas/Keyword' + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Unable to retrieve keyword data.' + /watchproviders/regions: + get: + summary: Get watch provider regions + description: | + Returns a list of all available watch provider regions. + tags: + - other + responses: + '200': + description: Watch provider regions returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderRegion' + /watchproviders/movies: + get: + summary: Get watch provider movies + description: | + Returns a list of all available watch providers for movies. + tags: + - other + parameters: + - in: query + name: watchRegion + required: true + schema: + type: string + example: US + responses: + '200': + description: Watch providers for movies returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + /watchproviders/tv: + get: + summary: Get watch provider series + description: | + Returns a list of all available watch providers for series. + tags: + - other + parameters: + - in: query + name: watchRegion + required: true + schema: + type: string + example: US + responses: + '200': + description: Watch providers for series returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + /certifications/movie: + get: + summary: Get movie certifications + description: Returns list of movie certifications from TMDB. + tags: + - other + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: Movie certifications returned + content: + application/json: + schema: + $ref: '#/components/schemas/CertificationResponse' + '500': + description: Unable to retrieve movie certifications + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 500 + message: + type: string + example: Unable to retrieve movie certifications. + /certifications/tv: + get: + summary: Get TV certifications + description: Returns list of TV show certifications from TMDB. + tags: + - other + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: TV certifications returned + content: + application/json: + schema: + $ref: '#/components/schemas/CertificationResponse' + '500': + description: Unable to retrieve TV certifications + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 500 + message: + type: string + example: Unable to retrieve TV certifications. + /overrideRule: + get: + summary: Get override rules + description: Returns a list of all override rules with their conditions and settings + tags: + - overriderule + responses: + '200': + description: Override rules returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + post: + summary: Create override rule + description: Creates a new Override Rule from the request body. + tags: + - overriderule + responses: + '200': + description: 'Values were successfully created' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + /overrideRule/{ruleId}: + put: + summary: Update override rule + description: Updates an Override Rule from the request body. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: integer + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + delete: + summary: Delete override rule by ID + description: Deletes the override rule with the provided ruleId. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: integer + responses: + '200': + description: Override rule successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/OverrideRule' +security: + - cookieAuth: [] + - apiKey: [] diff --git a/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache b/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache new file mode 100644 index 00000000..33cec807 --- /dev/null +++ b/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache @@ -0,0 +1,191 @@ +package {{packageName}}.infrastructure + +{{#moshi}} +import com.squareup.moshi.Moshi +{{#enumUnknownDefaultCase}} +import com.squareup.moshi.adapters.EnumJsonAdapter +{{/enumUnknownDefaultCase}} +{{^moshiCodeGen}} +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +{{/moshiCodeGen}} +{{/moshi}} +{{#gson}} +import com.google.gson.Gson +import com.google.gson.GsonBuilder +{{^threetenbp}} +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +{{/threetenbp}} +{{#threetenbp}} +import org.threeten.bp.LocalDate +import org.threeten.bp.LocalDateTime +import org.threeten.bp.OffsetDateTime +{{/threetenbp}} +{{#kotlinx-datetime}} +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +{{/kotlinx-datetime}} +import java.util.UUID +{{/gson}} +{{#jackson}} +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +{{/jackson}} +{{#kotlinx_serialization}} +import java.math.BigDecimal +import java.math.BigInteger +{{^threetenbp}} +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +{{/threetenbp}} +{{#threetenbp}} +import org.threeten.bp.LocalDate +import org.threeten.bp.LocalDateTime +import org.threeten.bp.OffsetDateTime +{{/threetenbp}} +import java.util.UUID +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonBuilder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.SerializersModuleBuilder +import java.net.URI +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +{{/kotlinx_serialization}} + +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}object Serializer { +{{#moshi}} + @JvmStatic + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshiBuilder: Moshi.Builder = Moshi.Builder() + .add(OffsetDateTimeAdapter()) + {{#kotlinx-datetime}} + .add(InstantAdapter()) + .add(LocalDateAdapter()) + .add(LocalTimeAdapter()) + {{/kotlinx-datetime}} + .add(LocalDateTimeAdapter()) + .add(LocalDateAdapter()) + .add(UUIDAdapter()) + .add(ByteArrayAdapter()) + .add(URIAdapter()) + {{^moshiCodeGen}} + .add(KotlinJsonAdapterFactory()) + {{/moshiCodeGen}} + .add(BigDecimalAdapter()) + .add(BigIntegerAdapter()) + + @JvmStatic + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshi: Moshi by lazy { +{{#enumUnknownDefaultCase}} + SerializerHelper.addEnumUnknownDefaultCase(moshiBuilder) +{{/enumUnknownDefaultCase}} + moshiBuilder.build() + } +{{/moshi}} +{{#gson}} + @JvmStatic + val gsonBuilder: GsonBuilder = GsonBuilder() + .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeAdapter()) + {{#kotlinx-datetime}} + .registerTypeAdapter(Instant::class.java, InstantAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .registerTypeAdapter(LocalTime::class.java, LocalTimeAdapter()) + {{/kotlinx-datetime}} + .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter()) + {{#generateOneOfAnyOfWrappers}} + {{#models}} + {{#model}} + {{^isEnum}} + {{^hasChildren}} + .registerTypeAdapterFactory({{modelPackage}}.{{{classname}}}.CustomTypeAdapterFactory()) + {{/hasChildren}} + {{/isEnum}} + {{/model}} + {{/models}} + {{/generateOneOfAnyOfWrappers}} + + @JvmStatic + val gson: Gson by lazy { + gsonBuilder.create() + } +{{/gson}} +{{#jackson}} + @JvmStatic + val jacksonObjectMapper: ObjectMapper = jacksonObjectMapper() + .findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_ABSENT) + {{#enumUnknownDefaultCase}} + .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true) + {{/enumUnknownDefaultCase}} + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, {{failOnUnknownProperties}}) +{{/jackson}} +{{#kotlinx_serialization}} + private var isAdaptersInitialized = false + + @JvmStatic + val kotlinxSerializationAdapters: SerializersModule by lazy { + isAdaptersInitialized = true + SerializersModule { + contextual(BigDecimal::class, BigDecimalAdapter) + contextual(BigInteger::class, BigIntegerAdapter) + {{^kotlinx-datetime}} + contextual(LocalDate::class, LocalDateAdapter) + contextual(LocalDateTime::class, LocalDateTimeAdapter) + contextual(OffsetDateTime::class, OffsetDateTimeAdapter) + {{/kotlinx-datetime}} + contextual(UUID::class, UUIDAdapter) + contextual(AtomicInteger::class, AtomicIntegerAdapter) + contextual(AtomicLong::class, AtomicLongAdapter) + contextual(AtomicBoolean::class, AtomicBooleanAdapter) + contextual(URI::class, URIAdapter) + contextual(URL::class, URLAdapter) + contextual(StringBuilder::class, StringBuilderAdapter) + + apply(kotlinxSerializationAdaptersConfiguration) + } + } + + var kotlinxSerializationAdaptersConfiguration: SerializersModuleBuilder.() -> Unit = {} + set(value) { + check(!isAdaptersInitialized) { + "Cannot configure kotlinxSerializationAdaptersConfiguration after kotlinxSerializationAdapters has been initialized." + } + field = value + } + + private var isJsonInitialized = false + + @JvmStatic + val kotlinxSerializationJson: Json by lazy { + isJsonInitialized = true + Json { + serializersModule = kotlinxSerializationAdapters + encodeDefaults = true + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + + apply(kotlinxSerializationJsonConfiguration) + } + } + + var kotlinxSerializationJsonConfiguration: JsonBuilder.() -> Unit = {} + set(value) { + check(!isJsonInitialized) { + "Cannot configure kotlinxSerializationJsonConfiguration after kotlinxSerializationJson has been initialized." + } + field = value + } +{{/kotlinx_serialization}} +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73f5a287..c1b0676f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,12 +9,14 @@ hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kotlin = "2.3.0" +kotlinxCoroutinesCore = "1.10.2" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" mockk = "1.14.7" multiplatformMarkdownRenderer = "0.39.0" +okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -38,6 +40,7 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.0" paletteKtx = "1.0.0" +openapi-generator = "7.18.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -73,10 +76,13 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } +okhttp = { module = "com.squareup.okhttp3:okhttp" } +okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -127,3 +133,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } room = { id = "androidx.room", version.ref = "room" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } +openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c9d815b7..4b95efe8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,9 +9,6 @@ pluginManagement { } mavenCentral() gradlePluginPortal() -// maven { -// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") -// } } } dependencyResolutionManagement { @@ -19,9 +16,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() -// maven { -// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") -// } } }