mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
## 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>
306 lines
10 KiB
Kotlin
306 lines
10 KiB
Kotlin
import com.google.protobuf.gradle.id
|
|
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
|
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
import java.util.Base64
|
|
import java.util.Properties
|
|
|
|
plugins {
|
|
alias(libs.plugins.android.application)
|
|
alias(libs.plugins.kotlin.android)
|
|
alias(libs.plugins.ksp)
|
|
alias(libs.plugins.kotlin.compose)
|
|
alias(libs.plugins.hilt)
|
|
alias(libs.plugins.room)
|
|
alias(libs.plugins.protobuf)
|
|
alias(libs.plugins.kotlin.plugin.serialization)
|
|
alias(libs.plugins.aboutLibraries)
|
|
alias(libs.plugins.openapi.generator)
|
|
}
|
|
|
|
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
|
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
|
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
|
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
|
|
|
val gitTags =
|
|
providers
|
|
.exec { commandLine("git", "tag", "--list", "v*", "p*") }
|
|
.standardOutput.asText
|
|
.get()
|
|
|
|
val gitDescribe =
|
|
providers
|
|
.exec { commandLine("git", "describe", "--tags", "--long", "--match=v*") }
|
|
.standardOutput.asText
|
|
.getOrElse("v0.0.0")
|
|
|
|
android {
|
|
namespace = "com.github.damontecres.wholphin"
|
|
compileSdk = 36
|
|
|
|
defaultConfig {
|
|
applicationId = "com.github.damontecres.wholphin"
|
|
minSdk = 23
|
|
targetSdk = 36
|
|
versionCode = gitTags.trim().lines().size
|
|
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
}
|
|
|
|
buildTypes {
|
|
release {
|
|
isMinifyEnabled = true
|
|
proguardFiles(
|
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
"proguard-rules.pro",
|
|
)
|
|
isDebuggable = false
|
|
}
|
|
debug {
|
|
isMinifyEnabled = false
|
|
isDebuggable = true
|
|
applicationIdSuffix = ".debug"
|
|
}
|
|
}
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_11
|
|
targetCompatibility = JavaVersion.VERSION_11
|
|
isCoreLibraryDesugaringEnabled = true
|
|
}
|
|
kotlin {
|
|
compilerOptions {
|
|
jvmTarget = JvmTarget.JVM_11
|
|
javaParameters = true
|
|
}
|
|
}
|
|
buildFeatures {
|
|
buildConfig = true
|
|
compose = true
|
|
}
|
|
room {
|
|
schemaDirectory("$projectDir/schemas")
|
|
}
|
|
signingConfigs {
|
|
if (shouldSign) {
|
|
create("ci") {
|
|
file("ci.keystore").writeBytes(
|
|
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
|
|
)
|
|
keyAlias = System.getenv("KEY_ALIAS")
|
|
keyPassword = System.getenv("KEY_PASSWORD")
|
|
storePassword = System.getenv("KEY_STORE_PASSWORD")
|
|
storeFile = file("ci.keystore")
|
|
enableV1Signing = true
|
|
enableV2Signing = true
|
|
enableV3Signing = true
|
|
enableV4Signing = true
|
|
}
|
|
}
|
|
}
|
|
buildTypes {
|
|
release {
|
|
isMinifyEnabled = false
|
|
|
|
proguardFiles(
|
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
"proguard-rules.pro",
|
|
)
|
|
if (shouldSign) {
|
|
signingConfig = signingConfigs.getByName("ci")
|
|
} else {
|
|
val localPropertiesFile = project.rootProject.file("local.properties")
|
|
if (localPropertiesFile.exists()) {
|
|
val properties = Properties()
|
|
properties.load(localPropertiesFile.inputStream())
|
|
val signingConfigName = properties["release.signing.config"]?.toString()
|
|
if (signingConfigName != null) {
|
|
signingConfig = signingConfigs.getByName(signingConfigName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
debug {
|
|
if (shouldSign) {
|
|
signingConfig = signingConfigs.getByName("ci")
|
|
}
|
|
}
|
|
|
|
applicationVariants.all {
|
|
val variant = this
|
|
variant.outputs
|
|
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
|
|
.forEach { output ->
|
|
val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" }
|
|
val outputFileName =
|
|
"Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk"
|
|
output.outputFileName = outputFileName
|
|
}
|
|
}
|
|
}
|
|
|
|
splits {
|
|
abi {
|
|
isEnable = true
|
|
reset()
|
|
include("armeabi-v7a", "arm64-v8a")
|
|
isUniversalApk = true
|
|
}
|
|
}
|
|
|
|
sourceSets {
|
|
getByName("main") {
|
|
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
|
|
}
|
|
}
|
|
}
|
|
|
|
protobuf {
|
|
protoc {
|
|
artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}"
|
|
}
|
|
generateProtoTasks {
|
|
all().forEach {
|
|
it.plugins {
|
|
id("java") {
|
|
option("lite")
|
|
}
|
|
}
|
|
it.builtins {
|
|
id("kotlin") {
|
|
option("lite")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
aboutLibraries {
|
|
collect {
|
|
configPath = file("config")
|
|
}
|
|
library {
|
|
duplicationMode = DuplicateMode.MERGE
|
|
duplicationRule = DuplicateRule.SIMPLE
|
|
}
|
|
}
|
|
|
|
openApiGenerate {
|
|
generatorName.set("kotlin")
|
|
inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml")
|
|
templateDir.set("$projectDir/src/main/seerr/templates")
|
|
outputDir.set("$buildDir/generated/seerr_api")
|
|
apiPackage.set("com.github.damontecres.wholphin.api.seerr")
|
|
modelPackage.set("com.github.damontecres.wholphin.api.seerr.model")
|
|
groupId.set("com.github.damontecres.wholphin.api.seerr")
|
|
id.set("seerr-api")
|
|
packageName.set("com.github.damontecres.wholphin.api.seerr")
|
|
additionalProperties.apply {
|
|
put("serializationLibrary", "kotlinx_serialization")
|
|
put("sortModelPropertiesByRequiredFlag", true)
|
|
put("sortParamsByRequiredFlag", true)
|
|
put("useCoroutines", true)
|
|
put("enumPropertyNaming", "UPPERCASE")
|
|
put("modelMutable", false)
|
|
|
|
// Note: this is only for downloading files, so it's not necessary to enable
|
|
put("supportAndroidApiLevel25AndBelow", false)
|
|
}
|
|
}
|
|
|
|
tasks.named("preBuild") {
|
|
dependsOn.add(tasks.named("openApiGenerate"))
|
|
}
|
|
|
|
dependencies {
|
|
implementation(libs.androidx.core.ktx)
|
|
implementation(libs.androidx.appcompat)
|
|
implementation(platform(libs.androidx.compose.bom))
|
|
implementation(libs.androidx.compose.ui)
|
|
implementation(libs.androidx.compose.ui.graphics)
|
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
|
implementation(libs.androidx.compose.runtime)
|
|
implementation(libs.androidx.compose.runtime.livedata)
|
|
implementation(libs.androidx.tv.foundation)
|
|
implementation(libs.androidx.tv.material)
|
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
implementation(libs.androidx.lifecycle.livedata.ktx)
|
|
implementation(libs.androidx.activity.compose)
|
|
implementation(libs.androidx.datastore)
|
|
implementation(libs.protobuf.kotlin.lite)
|
|
implementation(libs.androidx.tvprovider)
|
|
implementation(libs.androidx.work.runtime.ktx)
|
|
implementation(libs.androidx.hilt.work)
|
|
|
|
implementation(libs.androidx.media3.exoplayer)
|
|
implementation(libs.androidx.media3.session)
|
|
implementation(libs.androidx.media3.datasource.okhttp)
|
|
implementation(libs.androidx.media3.exoplayer.hls)
|
|
implementation(libs.androidx.media3.exoplayer.dash)
|
|
implementation(libs.androidx.media3.ui)
|
|
implementation(libs.androidx.media3.ui.compose)
|
|
|
|
implementation(libs.coil.core)
|
|
implementation(libs.coil.compose)
|
|
implementation(libs.coil.network.cachecontrol)
|
|
implementation(libs.coil.network.okhttp)
|
|
implementation(libs.coil.gif)
|
|
implementation(libs.coil.svg)
|
|
|
|
implementation(libs.jellyfin.core)
|
|
implementation(libs.jellyfin.api)
|
|
implementation(libs.jellyfin.api.okhttp)
|
|
|
|
implementation(libs.androidx.navigation3.ui)
|
|
implementation(libs.androidx.navigation3.runtime)
|
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
|
implementation(libs.androidx.material3.adaptive.navigation3)
|
|
implementation(libs.kotlinx.serialization.core)
|
|
implementation(libs.kotlinx.serialization.json)
|
|
|
|
implementation(libs.hilt.android)
|
|
implementation(libs.androidx.room.common.jvm)
|
|
implementation(libs.androidx.room.ktx)
|
|
implementation(libs.androidx.compose.material3)
|
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
|
implementation(libs.androidx.hilt.navigation.compose)
|
|
implementation(libs.androidx.preference.ktx)
|
|
implementation(libs.androidx.room.testing)
|
|
implementation(libs.androidx.palette.ktx)
|
|
implementation(libs.androidx.media3.effect)
|
|
ksp(libs.androidx.room.compiler)
|
|
ksp(libs.hilt.android.compiler)
|
|
ksp(libs.androidx.hilt.compiler)
|
|
|
|
implementation(libs.timber)
|
|
implementation(libs.slf4j2.timber)
|
|
implementation(libs.aboutlibraries.core)
|
|
implementation(libs.aboutlibraries.compose.m3)
|
|
implementation(libs.multiplatform.markdown.renderer)
|
|
implementation(libs.multiplatform.markdown.renderer.m3)
|
|
implementation(libs.programguide)
|
|
implementation(libs.acra.http)
|
|
implementation(libs.acra.dialog)
|
|
implementation(libs.acra.limiter)
|
|
compileOnly(libs.auto.service.annotations)
|
|
ksp(libs.auto.service.ksp)
|
|
implementation(platform(libs.okhttp.bom))
|
|
implementation(libs.okhttp)
|
|
|
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
|
if (ffmpegModuleExists || isCI) {
|
|
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
|
}
|
|
if (av1ModuleExists || isCI) {
|
|
implementation(files("libs/lib-decoder-av1-release.aar"))
|
|
}
|
|
|
|
testImplementation(libs.mockk.android)
|
|
testImplementation(libs.mockk.agent)
|
|
testImplementation(libs.kotlinx.coroutines.test)
|
|
testImplementation(libs.androidx.core.testing)
|
|
testImplementation(libs.robolectric)
|
|
}
|