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

View file

@ -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<NavDrawerItem> {
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 }

View file

@ -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<SeerrUser>
@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<SeerrServerUsers>
@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?
}

View file

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

View file

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

View file

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

View file

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