Fix suggestions mixing content from different libraries and update recommendation logic (#644)

## Description
This PR aims to fix #638 where the "Suggestions" row was mixing up
content from different libraries that shared the same library type. For
example you were in the Anime library, you'd see regular TV shows
showing up, as the TV Shows and Anime libraries would both be "Show"
libraries.

As @damontecres mentioned in the issue discussion, the
`/Items/Suggestions` endpoint Wholphin is using just returns random
items from the same library type, with no special logic or
recommendation algorithm.

I also looked at how the official WebUI handles this. It basically just
mixes "Resume," "Next Up," and "Latest Items" together. That felt a bit
redundant since we usually have dedicated rows for those things anyway;
I wanted to try something that helps discover new content.

I replaced the broken endpoint with a few custom `GetItemsRequest`
calls. These work correctly with the `ParentId` filter, so we only get
results from the library we are actually looking at. Additionally, I
added in some logic that takes into account what the user has been
watching to tailor the suggestions a little bit.

The new logic uses a 40/30/30 mix (this can of course be tweaked as
needed)

- Tailored (40%): Grabs your recently watched items from this library,
checks their genres, and returns items from the same genre.

- Random (30%): Random unwatched stuff from this library.

- New (30%): Recently added items.

This is implemented using only `GetItemsRequest` API calls using
filters, rather than any specialized endpoints like the
`/Items/Suggestions` endpoint. This means the feature should be
predictable and stable. If Jellyfin ever adds an actual recommendation
algorithm and endpoint to call it, I imagine we would want to switch to
that in the future though.

I also added some failsafe logic to make sure there will be diversity in
the "recently watched" items that get pulled, in case a user might have
recently binged a TV show. We wouldn't want every single recommendation
to be based off of that one show.

To fix this, I changed the query to fetch the last 20 history items
instead of just the top 3. Then, we filter that list client-side to find
unique Series IDs. If the user watched 10 episodes of One Piece in a
row, the logic collapses them into a single "One Piece" entry and moves
on to the next distinct show they watched. This guarantees we get
variety in the source material for recommendations.

This is a work in progress right now, mainly due to performance. For my
server it takes about 10-15 seconds to load the Suggestions row when I
tested. This is unacceptable in my opinion, especially if we ever intend
to add a Suggestions row like this to the home page like Plex does. I
have some ideas on how to improve performance, but I'm open to
suggestions.

### Related issues
Resolves #638 

### Screenshots

Before:

<img width="1953" height="1162" alt="suggestions1"
src="https://github.com/user-attachments/assets/6e301c67-1a46-46b5-8184-3adb9e52b330"
/>


After:

<img width="1937" height="1108" alt="suggestions3"
src="https://github.com/user-attachments/assets/b9563865-4055-40b6-b452-f94c26c8b6e9"
/>


### AI/LLM usage
I used Claude Code to help write the Kotlin Coroutines so the API calls
run at the same time. I also used it to write the tests in
`TestSuggestionsLogic.kt`.

---------

Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
Justin Caveda 2026-02-04 19:50:29 -06:00 committed by GitHub
parent d9d5ab02e8
commit aeaecc0f59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1809 additions and 76 deletions

View file

@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@ -16,6 +19,7 @@ class PlaybackLifecycleObserver
private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer,
private val suggestionsCache: SuggestionsCache,
) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null
@ -48,5 +52,6 @@ class PlaybackLifecycleObserver
override fun onStop(owner: LifecycleOwner) {
themeSongPlayer.stop()
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
}
}

View file

@ -0,0 +1,97 @@
package com.github.damontecres.wholphin.services
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
sealed interface SuggestionsResource {
data object Loading : SuggestionsResource
data class Success(
val items: List<BaseItem>,
) : SuggestionsResource
data object Empty : SuggestionsResource
}
@Singleton
class SuggestionService
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val cache: SuggestionsCache,
private val workManager: WorkManager,
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun getSuggestionsFlow(
parentId: UUID,
itemKind: BaseItemKind,
): Flow<SuggestionsResource> {
return serverRepository.currentUser
.asFlow()
.flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
cache.cacheVersion
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
.distinctUntilChanged()
.flatMapLatest { cachedIds ->
if (cachedIds.isNotEmpty()) {
flow {
try {
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
val isActive =
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
}
}
}
}
private suspend fun fetchItemsByIds(
ids: List<UUID>,
itemKind: BaseItemKind,
): List<BaseItem> {
val isSeries = itemKind == BaseItemKind.SERIES
val request =
GetItemsRequest(
ids = ids,
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW),
)
return GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem.from(it, api, isSeries) }
}
}

View file

@ -0,0 +1,216 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
data class CachedSuggestions(
val ids: List<UUID>,
)
@Singleton
class SuggestionsCache
@Inject
constructor(
@param:ApplicationContext private val context: Context,
) {
private val json = Json { ignoreUnknownKeys = true }
private val _cacheVersion = MutableStateFlow(0L)
val cacheVersion: StateFlow<Long> = _cacheVersion.asStateFlow()
private val memoryCache: MutableMap<String, CachedSuggestions> =
LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true)
@Volatile
private var diskCacheLoadedUserId: UUID? = null
private val dirtyKeys: MutableSet<String> = mutableSetOf()
private val mutex = Mutex()
@OptIn(ExperimentalSerializationApi::class)
private fun writeEntryToDisk(
key: String,
cached: CachedSuggestions,
) {
runCatching {
val suggestionsDir = cacheDir.apply { mkdirs() }
File(suggestionsDir, "$key.json")
.outputStream()
.use { json.encodeToStream(cached, it) }
}.onFailure { Timber.w(it, "Failed to write evicted cache: $key") }
}
private fun checkForEviction(newKey: String): Pair<String, CachedSuggestions>? {
if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) {
return null
}
val eldest = memoryCache.entries.firstOrNull() ?: return null
memoryCache.remove(eldest.key)
return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null
}
private fun cacheKey(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
private val cacheDir: File
get() = File(context.cacheDir, "suggestions")
@OptIn(ExperimentalSerializationApi::class)
private suspend fun loadFromDisk(userId: UUID) {
if (diskCacheLoadedUserId == userId) return
mutex.withLock {
if (diskCacheLoadedUserId == userId) return@withLock
withContext(Dispatchers.IO) {
val suggestionsDir = cacheDir
if (!suggestionsDir.exists()) {
diskCacheLoadedUserId = userId
return@withContext
}
memoryCache.clear()
suggestionsDir
.listFiles {
it.name.startsWith(userId.toServerString())
}.orEmpty()
.take(MAX_MEMORY_CACHE_SIZE)
.forEach { file ->
runCatching {
val key = file.nameWithoutExtension
val cached =
file
.inputStream()
.use { json.decodeFromStream<CachedSuggestions>(it) }
memoryCache[key] = cached
}.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") }
}
diskCacheLoadedUserId = userId
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun get(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
): CachedSuggestions? {
loadFromDisk(userId)
val key = cacheKey(userId, libraryId, itemKind)
memoryCache[key]?.let { return it }
return withContext(Dispatchers.IO) {
runCatching {
File(cacheDir, "$key.json")
.takeIf { it.exists() }
?.inputStream()
?.use {
json.decodeFromStream<CachedSuggestions>(it)
}?.also { memoryCache[key] = it }
}.onFailure { Timber.w(it, "Failed to read cache: $key") }
.getOrNull()
}
}
suspend fun put(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
ids: List<UUID>,
) {
val key = cacheKey(userId, libraryId, itemKind)
val cached = CachedSuggestions(ids)
val evictedEntry =
mutex.withLock {
val evicted = checkForEviction(key)
memoryCache[key] = cached
dirtyKeys.add(key)
_cacheVersion.update { it + 1 }
evicted
}
evictedEntry?.let { (evictedKey, evictedValue) ->
withContext(Dispatchers.IO) {
writeEntryToDisk(evictedKey, evictedValue)
}
}
}
suspend fun isEmpty(): Boolean =
mutex.withLock {
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
return@withLock false
}
withContext(Dispatchers.IO) {
val files = cacheDir.listFiles()
files == null || files.isEmpty()
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun save() {
val entriesToSave =
mutex.withLock {
if (dirtyKeys.isEmpty()) return
val entries =
dirtyKeys.mapNotNull { key ->
memoryCache[key]?.let { key to it }
}
dirtyKeys.clear()
entries
}
withContext(Dispatchers.IO) {
val suggestionsDir =
cacheDir.apply {
if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory")
}
entriesToSave.forEach { (key, value) ->
runCatching {
File(suggestionsDir, "$key.json")
.outputStream()
.use { json.encodeToStream(value, it) }
}.onFailure { Timber.w(it, "Failed to write cache: $key") }
}
}
}
suspend fun clear() {
mutex.withLock {
memoryCache.clear()
dirtyKeys.clear()
_cacheVersion.update { it + 1 }
diskCacheLoadedUserId = null
}
withContext(Dispatchers.IO) {
runCatching { cacheDir.deleteRecursively() }
.onFailure { Timber.w(it, "Failed to clear suggestions cache") }
}
}
companion object {
private const val MAX_MEMORY_CACHE_SIZE = 8
}
}

View file

@ -0,0 +1,100 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
@ActivityScoped
class SuggestionsSchedulerService
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val cache: SuggestionsCache,
private val workManager: WorkManager,
) {
private val activity =
(context as? AppCompatActivity)
?: throw IllegalStateException(
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
// Exposed for testing
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
init {
serverRepository.current.observe(activity) { user ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
} else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id)
}
}
}
}
private suspend fun scheduleWork(
userId: UUID,
serverId: UUID,
) {
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData =
workDataOf(
SuggestionsWorker.PARAM_USER_ID to userId.toString(),
SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
)
if (cache.isEmpty()) {
Timber.i("Suggestions cache empty, scheduling immediate fetch")
workManager.enqueue(
OneTimeWorkRequestBuilder<SuggestionsWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.build(),
)
}
Timber.i("Scheduling periodic SuggestionsWorker")
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
request =
PeriodicWorkRequestBuilder<SuggestionsWorker>(
repeatInterval = 12.hours.toJavaDuration(),
).setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).setInputData(inputData)
.build(),
)
}
}

View file

@ -0,0 +1,230 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.supervisorScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
@HiltWorker
class SuggestionsWorker
@AssistedInject
constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val serverRepository: ServerRepository,
private val preferences: DataStore<AppPreferences>,
private val api: ApiClient,
private val cache: SuggestionsCache,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Start")
val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
}
try {
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val itemsPerRow =
prefs.homePagePreferences.maxItemsPerRow
.takeIf { it > 0 }
?: AppPreference.HomePageItems.defaultValue.toInt()
val views =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
.orEmpty()
if (views.isEmpty()) {
return Result.success()
}
val results =
supervisorScope {
views
.mapNotNull { view ->
val itemKind =
when (view.collectionType) {
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> return@mapNotNull null
}
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for view %s", view.id)
val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow)
ensureActive()
cache.put(
userId,
view.id,
itemKind,
suggestions.map { it.id },
)
}.onFailure { e ->
Timber.e(
e,
"Failed to fetch suggestions for view %s",
view.id,
)
}
}
}.awaitAll()
}
val successCount = results.count { it.isSuccess }
val failureCount = results.count { it.isFailure }
cache.save()
if (failureCount > 0 && successCount == 0) {
Timber.w("All attempts failed ($failureCount views), scheduling retry")
return Result.retry()
}
Timber.d("Completed with $successCount successes and $failureCount failures")
return Result.success()
} catch (ex: ApiClientException) {
Timber.w(ex, "SuggestionsWorker ApiClientException, will retry")
return Result.retry()
} catch (e: Exception) {
Timber.e(e, "SuggestionsWorker failed")
return Result.failure()
}
}
private suspend fun fetchSuggestions(
parentId: UUID,
userId: UUID,
itemKind: BaseItemKind,
itemsPerRow: Int,
): List<BaseItemDto> =
coroutineScope {
val isSeries = itemKind == BaseItemKind.SERIES
val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind
val historyDeferred =
async(Dispatchers.IO) {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = historyItemType,
sortBy = ItemSortBy.DATE_PLAYED,
isPlayed = true,
limit = 10,
extraFields = listOf(ItemFields.GENRES),
).distinctBy { it.relevantId }.take(3)
}
val randomDeferred =
async(Dispatchers.IO) {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = itemKind,
sortBy = ItemSortBy.RANDOM,
isPlayed = false,
limit = itemsPerRow,
)
}
val freshDeferred =
async(Dispatchers.IO) {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = itemKind,
sortBy = ItemSortBy.DATE_CREATED,
sortOrder = SortOrder.DESCENDING,
isPlayed = false,
limit = (itemsPerRow * FRESH_CONTENT_RATIO).toInt().coerceAtLeast(1),
)
}
val seedItems = historyDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
(fresh + random)
.asSequence()
.distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) }
.toList()
.shuffled()
.take(itemsPerRow)
}
private suspend fun fetchItems(
parentId: UUID,
userId: UUID,
itemKind: BaseItemKind,
sortBy: ItemSortBy,
isPlayed: Boolean,
limit: Int,
sortOrder: SortOrder? = null,
genreIds: List<UUID>? = null,
extraFields: List<ItemFields> = emptyList(),
): List<BaseItemDto> {
val request =
GetItemsRequest(
parentId = parentId,
userId = userId,
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields,
includeItemTypes = listOf(itemKind),
genreIds = genreIds,
recursive = true,
isPlayed = isPlayed,
sortBy = listOf(sortBy),
sortOrder = sortOrder?.let { listOf(it) },
limit = limit,
enableTotalRecordCount = false,
imageTypeLimit = 1,
)
return GetItemsRequestHandler
.execute(api, request)
.content.items
.orEmpty()
}
companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
private const val FRESH_CONTENT_RATIO = 0.4
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
@ -176,6 +177,12 @@ object AppModule {
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun workManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
@Provides
@Singleton
fun seerrApi(

View file

@ -29,9 +29,9 @@ class TvProviderSchedulerService
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val workManager: WorkManager,
) {
private val activity = (context as AppCompatActivity)
private val workManager = WorkManager.getInstance(context)
private val supportsTvProvider =
// TODO <=25 has limited support