Updates to Seerr image fetching (#1086)

## Description
This PR makes changes to how images are fetched for discover/Seerr
items.

If the discovered item is available on the Jellyfin server, the images
will be fetched from the Jellyfin server.

Then if the Seerr server has image caching enabled, the images will be
fetched from the Seerr server. Otherwise, images are fetched from TMDB .
TMDB was the previous behavior for all images.

### Dev notes

This PR also updates the Seerr server URLs stored in the database, so
it's not backwards compatible.

### Related issues
Fixes #1079

### Testing
Emulator against Jellyseer 2.7.3 & Seerr 3.0.1

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-14 16:47:08 -04:00 committed by GitHub
parent 2da55bb616
commit 627b89f1db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 536 additions and 327 deletions

View file

@ -93,9 +93,6 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var updateChecker: UpdateChecker
@Inject
lateinit var appUpgradeHandler: AppUpgradeHandler
@Inject
lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver
@ -136,11 +133,7 @@ class MainActivity : AppCompatActivity() {
instance = this
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
lifecycleScope.launchIO {
appUpgradeHandler.copySubfont(false)
}
}
viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) {
window?.setFlags(
@ -249,7 +242,6 @@ class MainActivity : AppCompatActivity() {
Timber.d("onResume")
lifecycleScope.launchDefault {
screensaverService.pulse()
appUpgradeHandler.run()
}
}
@ -384,10 +376,13 @@ class MainActivityViewModel
private val navigationManager: SetupNavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
) : ViewModel() {
fun appStart() {
viewModelScope.launchIO {
try {
appUpgradeHandler.run()
appUpgradeHandler.copySubfont(false)
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val userHasPin = serverRepository.currentUser.value?.hasPin == true

View file

@ -3,25 +3,16 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
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
@ -85,17 +76,14 @@ data class DiscoverItem(
val overview: String?,
val availability: SeerrAvailability,
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
val posterPath: String?,
val backdropPath: String?,
val posterUrl: String?,
val backDropUrl: 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_h1080_multi_faces$it" }
val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }
val destination: Destination
get() {
val jfType =
@ -114,103 +102,6 @@ data class DiscoverItem(
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 = credit.character,
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 = credit.job,
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(

View file

@ -6,6 +6,7 @@ import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.BaseItemKind
@ -35,9 +37,14 @@ class AppUpgradeHandler
constructor(
@param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>,
private val seerrServerDao: SeerrServerDao,
) {
suspend fun run() {
val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0)
val pkgInfo =
WholphinApplication.instance.packageManager.getPackageInfo(
WholphinApplication.instance.packageName,
0,
)
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
@ -62,14 +69,16 @@ class AppUpgradeHandler
try {
copySubfont(true)
upgradeApp(
context,
Version.Companion.fromString(previousVersion ?: "0.0.0"),
Version.Companion.fromString(newVersion),
Version.fromString(previousVersion ?: "0.0.0"),
Version.fromString(newVersion),
appPreferences,
)
} catch (ex: Exception) {
Timber.e(ex, "Exception during app upgrade")
}
Timber.i("App upgrade complete")
} else {
Timber.d("No app update needed")
}
}
@ -100,110 +109,108 @@ class AppUpgradeHandler
const val VERSION_NAME_CURRENT_KEY = "version.current.name"
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
}
}
suspend fun upgradeApp(
context: Context,
previous: Version,
current: Version,
appPreferences: DataStore<AppPreferences>,
) {
if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) {
appPreferences.updateData {
it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
showClock = AppPreference.ShowClock.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) {
PreferencesViewModel.resetSubtitleSettings(appPreferences)
}
if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) {
appPreferences.updateData {
it.updateSubtitlePreferences {
margin = SubtitleSettings.Margin.defaultValue.toInt()
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) {
appPreferences.updateData {
it.updateAdvancedPreferences {
if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) {
imageDiskCacheSizeBytes =
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
suspend fun upgradeApp(
previous: Version,
current: Version,
appPreferences: DataStore<AppPreferences>,
) {
if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) {
appPreferences.updateData {
it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
}
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = AppPreference.MpvGpuNext.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) {
appPreferences.updateData {
it.update {
signInAutomatically = AppPreference.SignInAuto.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) {
appPreferences.updateData {
it.updateSubtitlePreferences {
if (edgeThickness < 1) {
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
}
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
appPreferences.updateData {
it.updateLiveTvPreferences {
showHeader = AppPreference.LiveTvShowHeader.defaultValue
favoriteChannelsAtBeginning =
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
sortByRecentlyWatched =
AppPreference.LiveTvChannelSortByWatched.defaultValue
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = false
if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
showClock = AppPreference.ShowClock.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) {
PreferencesViewModel.resetSubtitleSettings(appPreferences)
}
if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) {
appPreferences.updateData {
it.updateSubtitlePreferences {
margin = SubtitleSettings.Margin.defaultValue.toInt()
}
}
}
}
}
// TODO temporarily disabled until some MPV bugs are fixed
if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) {
appPreferences.updateData {
it.updateAdvancedPreferences {
if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) {
imageDiskCacheSizeBytes =
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = AppPreference.MpvGpuNext.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) {
appPreferences.updateData {
it.update {
signInAutomatically = AppPreference.SignInAuto.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) {
appPreferences.updateData {
it.updateSubtitlePreferences {
if (edgeThickness < 1) {
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
appPreferences.updateData {
it.updateLiveTvPreferences {
showHeader = AppPreference.LiveTvShowHeader.defaultValue
favoriteChannelsAtBeginning =
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
sortByRecentlyWatched =
AppPreference.LiveTvChannelSortByWatched.defaultValue
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = false
}
}
}
}
// TODO temporarily disabled until some MPV bugs are fixed
// if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) {
// appPreferences.updateData {
// it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV }
@ -211,56 +218,67 @@ suspend fun upgradeApp(
// showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG)
// }
if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = false
if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = false
}
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
subtitlesPreferences =
subtitlesPreferences
.toBuilder()
.apply {
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
}.build()
// Copy current subtitle prefs as HDR ones
hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
subtitlesPreferences =
subtitlesPreferences
.toBuilder()
.apply {
imageSubtitleOpacity =
SubtitleSettings.ImageOpacity.defaultValue.toInt()
}.build()
// Copy current subtitle prefs as HDR ones
hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
}
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
appPreferences.updateData {
it.updatePhotoPreferences {
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
appPreferences.updateData {
it.updatePhotoPreferences {
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
}
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
appPreferences.updateData {
it.updateHomePagePreferences {
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
appPreferences.updateData {
it.updateHomePagePreferences {
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}
}
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) {
appPreferences.updateData {
it.updateScreensaverPreferences {
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
duration = ScreensaverPreference.DEFAULT_DURATION
animate = ScreensaverPreference.Animate.defaultValue
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
clearItemTypes()
addItemTypes(BaseItemKind.MOVIE.serialName)
addItemTypes(BaseItemKind.SERIES.serialName)
if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) {
appPreferences.updateData {
it.updateScreensaverPreferences {
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
duration = ScreensaverPreference.DEFAULT_DURATION
animate = ScreensaverPreference.Animate.defaultValue
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
clearItemTypes()
addItemTypes(BaseItemKind.MOVIE.serialName)
addItemTypes(BaseItemKind.SERIES.serialName)
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.5.4-0-g0"))) {
seerrServerDao.getServers().forEach {
val server = it.server
seerrServerDao.updateServer(
server.copy(url = migrateSeerrUrl(server.url)),
)
}
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import okhttp3.OkHttpClient
/**
@ -24,6 +25,6 @@ class SeerrApi(
baseUrl: String,
apiKey: String?,
) {
api = SeerrApiClient(baseUrl, apiKey, okHttpClient)
api = SeerrApiClient(createSeerrApiUrl(baseUrl), apiKey, okHttpClient)
}
}

View file

@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -163,7 +165,7 @@ class SeerrServerRepository
val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY }
val api =
SeerrApiClient(
url,
createSeerrApiUrl(url),
apiKey,
okHttpClient
.newBuilder()
@ -331,3 +333,24 @@ class UserSwitchListener
}
}
}
fun CurrentSeerr?.imageUrlBuilder(
imageType: ImageType,
path: String?,
): String? {
if (this == null) return null
val cacheImages = serverConfig.cacheImages == true
val base =
if (cacheImages) {
server.url.removeSuffix("/") + "/imageproxy/tmdb"
} else {
"https://image.tmdb.org"
}
val prefix =
when (imageType) {
ImageType.PRIMARY -> "/t/p/w500"
ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
else -> throw IllegalArgumentException("Image type not supported: $imageType")
}
return "${base}${prefix}$path"
}

View file

@ -1,12 +1,25 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
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.MediaInfo
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.BaseItem
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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.toLocalDate
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import javax.inject.Inject
import javax.inject.Singleton
@ -23,6 +36,7 @@ class SeerrService
constructor(
private val seerApi: SeerrApi,
private val seerrServerRepository: SeerrServerRepository,
private val imageUrlService: ImageUrlService,
) {
val api: SeerrApiClient get() = seerApi.api
@ -41,35 +55,35 @@ class SeerrService
api.searchApi
.discoverTvGet(page = page)
.results
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
.orEmpty()
suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverMoviesGet(page = page)
.results
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
.orEmpty()
suspend fun trending(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverTrendingGet(page = page)
.results
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
.orEmpty()
suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverMoviesUpcomingGet(page = page)
.results
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
.orEmpty()
suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> =
api.searchApi
.discoverTvUpcomingGet(page = page)
.results
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
.orEmpty()
/**
@ -90,14 +104,14 @@ class SeerrService
api.moviesApi
.movieMovieIdSimilarGet(movieId = it)
.results
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
}
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> {
api.tvApi
.tvTvIdSimilarGet(tvId = it)
.results
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
}
BaseItemKind.PERSON -> {
@ -107,12 +121,12 @@ class SeerrService
val cast =
credits.cast
?.take(25)
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
.orEmpty()
val crew =
credits.crew
?.take(25)
?.map(::DiscoverItem)
?.map { createDiscoverItem(it) }
.orEmpty()
cast + crew
}
@ -138,4 +152,174 @@ class SeerrService
} else {
null
}
private suspend fun createImageUrl(
imageType: ImageType,
path: String?,
mediaInfo: MediaInfo?,
): String? {
if (mediaInfo != null) {
val itemId =
if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId.toUUIDOrNull()
} else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
} else {
null
}
if (itemId != null) {
return imageUrlService.getItemImageUrl(
itemId = itemId,
imageType = imageType,
)
}
}
val current = seerrServerRepository.current.firstOrNull() ?: return null
val cacheImages = current.serverConfig.cacheImages == true
val base =
if (cacheImages) {
current.server.url.removeSuffix("/") + "/imageproxy/tmdb"
} else {
"https://image.tmdb.org"
}
val prefix =
when (imageType) {
ImageType.PRIMARY -> "/t/p/w500"
ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
else -> throw IllegalArgumentException("Image type not supported: $imageType")
}
return "${base}${prefix}$path"
}
suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
DiscoverItem(
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),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
DiscoverItem(
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),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
DiscoverItem(
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),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
DiscoverItem(
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),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
DiscoverItem(
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),
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
DiscoverItem(
id = credit.id!!,
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
title = credit.name ?: credit.title,
subtitle = credit.character,
overview = credit.overview,
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,
credit.posterPath ?: credit.profilePath,
credit.mediaInfo,
),
backDropUrl =
createImageUrl(
ImageType.BACKDROP,
credit.backdropPath,
credit.mediaInfo,
),
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
DiscoverItem(
id = credit.id!!,
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
title = credit.name ?: credit.title,
subtitle = credit.job,
overview = credit.overview,
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,
credit.posterPath ?: credit.profilePath,
credit.mediaInfo,
),
backDropUrl =
createImageUrl(
ImageType.BACKDROP,
credit.backdropPath,
credit.mediaInfo,
),
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
}

View file

@ -103,7 +103,7 @@ class DiscoverMovieViewModel
) {
Timber.v("Init for movie %s", item.id)
val movie = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(movie)
val discoveredItem = seerrService.createDiscoverItem(movie)
backdropService.submit(discoveredItem)
updateCanCancel()
@ -121,7 +121,7 @@ class DiscoverMovieViewModel
seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 1)
.results
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
similar.setValueOnMain(result)
}
@ -130,7 +130,7 @@ class DiscoverMovieViewModel
seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
.results
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
recommended.setValueOnMain(result)
}
@ -138,11 +138,11 @@ class DiscoverMovieViewModel
val people =
movie.credits
?.cast
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty() +
movie.credits
?.crew
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
this@DiscoverMovieViewModel.people.setValueOnMain(people)
val trailers =

View file

@ -67,11 +67,11 @@ class DiscoverPersonViewModel
.let { credits ->
val cast =
credits.cast
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
val crew =
credits.crew
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
cast + crew
}

View file

@ -102,7 +102,7 @@ class DiscoverSeriesViewModel
) {
Timber.v("Init for tv %s", item.id)
val tv = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(tv)
val discoveredItem = seerrService.createDiscoverItem(tv)
backdropService.submit(discoveredItem)
updateSeasonStatus()
@ -121,7 +121,7 @@ class DiscoverSeriesViewModel
seerrService.api.tvApi
.tvTvIdSimilarGet(tvId = item.id, page = 1)
.results
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
similar.setValueOnMain(result)
}
@ -130,7 +130,7 @@ class DiscoverSeriesViewModel
seerrService.api.tvApi
.tvTvIdRecommendationsGet(tvId = item.id, page = 1)
.results
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
recommended.setValueOnMain(result)
}
@ -138,11 +138,11 @@ class DiscoverSeriesViewModel
val people =
tv.credits
?.cast
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty() +
tv.credits
?.crew
?.map(::DiscoverItem)
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people)

View file

@ -239,7 +239,9 @@ class SeriesViewModel
val tv =
if (active) {
try {
seerrService.getTvSeries(item)?.let { DiscoverItem(it) }
seerrService
.getTvSeries(item)
?.let { seerrService.createDiscoverItem(it) }
} catch (ex: Exception) {
Timber.e(ex)
null

View file

@ -85,13 +85,13 @@ class SeerrRequestsViewModel
seerrService.api.moviesApi
.movieMovieIdGet(
movieId = request.media.tmdbId,
).let { DiscoverItem(it) }
).let { seerrService.createDiscoverItem(it) }
}
SeerrItemType.TV -> {
seerrService.api.tvApi
.tvTvIdGet(tvId = request.media.tmdbId)
.let { DiscoverItem(it) }
.let { seerrService.createDiscoverItem(it) }
}
SeerrItemType.PERSON -> {

View file

@ -165,7 +165,7 @@ class SearchViewModel
val results =
seerrService
.search(query)
.map { DiscoverItem(it) }
.map { seerrService.createDiscoverItem(it) }
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
}

View file

@ -127,37 +127,50 @@ class SwitchSeerrViewModel
}
fun createUrls(url: String): List<HttpUrl> {
val urls = mutableListOf<String>()
val urls = mutableListOf<HttpUrl>()
if (url.startsWith("http://") || url.startsWith("https://")) {
urls.add(url)
val httpUrl = url.toHttpUrl()
urls.add(httpUrl)
if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) {
urls.add("$url:5055")
urls.add(httpUrl.newBuilder().port(5055).build())
}
} else {
urls.add("http://$url")
val httpUrl = "http://$url".toHttpUrl()
urls.add(httpUrl)
if (httpUrl.port == 80) {
urls.add("https://$url")
urls.add("http://$url:5055")
urls.add("https://$url:5055")
urls.add(httpUrl.newBuilder().scheme("https").build())
urls.add(httpUrl.newBuilder().port(5055).build())
urls.add(
httpUrl
.newBuilder()
.scheme("https")
.port(5055)
.build(),
)
} else {
urls.add("https://$url")
urls.add(httpUrl.newBuilder().scheme("https").build())
}
}
return urls.map { cleanUrl(it).toHttpUrl() }
return urls
}
private fun cleanUrl(url: String) =
if (!url.endsWith("/api/v1")) {
fun createSeerrApiUrl(url: String): String =
if (url.isBlank()) {
url
} else if (url.endsWith("/api/v1") || url.endsWith("/api/v1/")) {
url
} else {
url
.toHttpUrl()
.newBuilder()
.apply {
addPathSegment("api")
addPathSegment("v1")
}.build()
.addPathSegment("api")
.addPathSegment("v1")
.build()
.toString()
} else {
url
}
fun migrateSeerrUrl(url: String): String {
var url = url.removeSuffix("/api/v1/").removeSuffix("/api/v1")
if (!url.endsWith("/")) url += "/"
return url
}

View file

@ -711,6 +711,8 @@ components:
type: boolean
series4kEnabled:
type: boolean
cacheImages:
type: boolean
MovieResult:
type: object
required:

View file

@ -1,7 +1,9 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.ui.setup.seerr.createUrls
import org.junit.Assert
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import org.junit.Assert.assertEquals
import org.junit.Test
class TestSeerr {
@ -13,12 +15,12 @@ class TestSeerr {
val expected =
listOf(
"http://jellyseerr.com/api/v1",
"https://jellyseerr.com/api/v1",
"http://jellyseerr.com:5055/api/v1",
"https://jellyseerr.com:5055/api/v1",
"http://jellyseerr.com/",
"https://jellyseerr.com/",
"http://jellyseerr.com:5055/",
"https://jellyseerr.com:5055/",
)
Assert.assertEquals(expected, urls)
assertEquals(expected, urls)
}
@Test
@ -29,10 +31,10 @@ class TestSeerr {
val expected =
listOf(
"https://jellyseerr.com/api/v1",
"https://jellyseerr.com:5055/api/v1",
"https://jellyseerr.com/",
"https://jellyseerr.com:5055/",
)
Assert.assertEquals(expected, urls)
assertEquals(expected, urls)
}
@Test
@ -43,10 +45,10 @@ class TestSeerr {
val expected =
listOf(
"http://jellyseerr.com/api/v1",
"http://jellyseerr.com:5055/api/v1",
"http://jellyseerr.com/",
"http://jellyseerr.com:5055/",
)
Assert.assertEquals(expected, urls)
assertEquals(expected, urls)
}
@Test
@ -57,10 +59,10 @@ class TestSeerr {
val expected =
listOf(
"http://jellyseerr.com:5055/api/v1",
"https://jellyseerr.com:5055/api/v1",
"http://jellyseerr.com:5055/",
"https://jellyseerr.com:5055/",
)
Assert.assertEquals(expected, urls)
assertEquals(expected, urls)
}
@Test
@ -71,10 +73,10 @@ class TestSeerr {
val expected =
listOf(
"http://10.0.0.2:443/api/v1",
"https://10.0.0.2/api/v1",
"http://10.0.0.2:443/",
"https://10.0.0.2/",
)
Assert.assertEquals(expected, urls)
assertEquals(expected, urls)
}
@Test
@ -85,9 +87,87 @@ class TestSeerr {
val expected =
listOf(
"http://10.0.0.2:8080/api/v1",
"https://10.0.0.2:8080/api/v1",
"http://10.0.0.2:8080/",
"https://10.0.0.2:8080/",
)
Assert.assertEquals(expected, urls)
assertEquals(expected, urls)
}
@Test
fun testCreateUrls7() {
val urls =
createUrls("http://10.0.0.2:80")
.map { it.toString() }
val expected =
listOf(
"http://10.0.0.2/",
"http://10.0.0.2:5055/",
)
assertEquals(expected, urls)
}
@Test
fun testCreateUrls8() {
val urls =
createUrls("https://10.0.0.2:443")
.map { it.toString() }
val expected =
listOf(
"https://10.0.0.2/",
"https://10.0.0.2:5055/",
)
assertEquals(expected, urls)
}
@Test
fun `Test createUrls for path`() {
val urls =
createUrls("https://jellyseerr.com/seerr/")
.map { it.toString() }
val expected =
listOf(
"https://jellyseerr.com/seerr/",
"https://jellyseerr.com:5055/seerr/",
)
assertEquals(expected, urls)
}
@Test
fun `Test build api url`() {
var url = "https://jellyseerr.com/"
assertEquals("https://jellyseerr.com/api/v1", createSeerrApiUrl(url))
url = "https://jellyseerr.com/path"
assertEquals("https://jellyseerr.com/path/api/v1", createSeerrApiUrl(url))
url = "http://jellyseerr.com:5055/"
assertEquals("http://jellyseerr.com:5055/api/v1", createSeerrApiUrl(url))
url = "http://jellyseerr.com:7878/path/"
assertEquals("http://jellyseerr.com:7878/path/api/v1", createSeerrApiUrl(url))
url = "http://jellyseerr.com/api/v1"
assertEquals("http://jellyseerr.com/api/v1", createSeerrApiUrl(url))
url = "http://jellyseerr.com/api/v1/"
assertEquals("http://jellyseerr.com/api/v1/", createSeerrApiUrl(url))
url = "http://jellyseerr.com/path/api/v1"
assertEquals("http://jellyseerr.com/path/api/v1", createSeerrApiUrl(url))
}
@Test
fun `Test migration`() {
assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1"))
assertEquals("https://10.0.0.2/", migrateSeerrUrl("https://10.0.0.2/api/v1"))
assertEquals("http://10.0.0.2:5055/", migrateSeerrUrl("http://10.0.0.2:5055/api/v1"))
assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1/"))
assertEquals("http://10.0.0.2/path/", migrateSeerrUrl("http://10.0.0.2/path/api/v1"))
assertEquals("http://10.0.0.2/api/v1/", migrateSeerrUrl("http://10.0.0.2/api/v1/api/v1"))
assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/"))
assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2"))
}
}