Add integration with Jellyseerr/Seerr (#558)

## Description
This PR add the ability to integrate with a [Jellyseerr/Seerr
server](https://github.com/seerr-team/seerr).

### Features
- Login to the Seerr server using API Key, Jellyfin credentials, or
local Seerr credentials
    - Separate Seerr logins per user profile
- Search Seerr from the search page
- Discover media from Seerr on the nav drawer `Discover` page
    - Also from movie, series, or person pages
- Seamless integration with existing media, i.e. selecting media found
from Seerr that already exists on the Jellyfin server, will go directly
to the regular media page
- Mostly consistent UI between Jellyfin media & Seerr media
- Submit requests to Seerr
- Cancel submitted requests

### Screenshots

![discover](https://github.com/user-attachments/assets/f830b48f-e2f1-43f4-9680-9c6d4e071765)

### Related issues
Closes #54

## Acknowledgements

The `app/src/main/seerr/seerr-api.yml` file is copied & modified from
https://github.com/seerr-team/seerr/blob/develop/seerr-api.yml. I hope
to try to upstream some of the changes in the future.
This commit is contained in:
Ray 2026-01-12 16:35:27 -05:00 committed by GitHub
parent f4e1b0e171
commit 4c7c465c67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 13369 additions and 137 deletions

View file

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

View file

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

View file

@ -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<CurrentSeerr?>(null)
val current: StateFlow<CurrentSeerr?> = _current
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
/**
* Whether Seerr integration is currently active of not
*/
val active: Flow<Boolean> = 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)
}
}
}
}
}
}
}

View file

@ -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<SeerrSearchResult> =
api.searchApi
.searchGet(query = query, page = page)
.results
.orEmpty()
suspend fun discoverTv(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverTvGet(page = page)
.results
?.map(::DiscoverItem)
.orEmpty()
suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverMoviesGet(page = page)
.results
?.map(::DiscoverItem)
.orEmpty()
suspend fun trending(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverTrendingGet(page = page)
.results
?.map(::DiscoverItem)
.orEmpty()
suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverMoviesUpcomingGet(page = page)
.results
?.map(::DiscoverItem)
.orEmpty()
suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> =
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<DiscoverItem>? =
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
}
}

View file

@ -88,7 +88,6 @@ class TrailerService
navigateTo.invoke(
Destination.Playback(
itemId = trailer.baseItem.id,
item = trailer.baseItem,
positionMs = 0L,
),
)

View file

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

View file

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

View file

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