Requests: 'Not on Smithflix yet' home row and 'Request on Smithflix' search row via the Smithflix Hub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdkZRTCV92L7gbQX2Z5Ay
This commit is contained in:
Claude 2026-10-11 12:23:20 -04:00
parent 76a93b2efe
commit 7aa0c873a3
No known key found for this signature in database
8 changed files with 344 additions and 2 deletions

View file

@ -21,6 +21,8 @@ android {
// Smithflix: the server offered on first launch, and its address on the home network
buildConfigField("String", "SMITHFLIX_SERVER_URL", "\"https://media.smithflix.us\"")
buildConfigField("String", "SMITHFLIX_HOME_URL", "\"http://192.168.86.121:8096\"")
// Smithflix Hub: requests and "Not on Smithflix yet" suggestions
buildConfigField("String", "SMITHFLIX_HUB_URL", "\"https://app.smithflix.us\"")
// Smithflix: where the app looks for updates (published by the build workflow)
buildConfigField(

View file

@ -0,0 +1,109 @@
package org.jellyfin.androidtv.smithflix
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.sdk.api.client.ApiClient
import org.json.JSONArray
import org.json.JSONObject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** A movie or show from the Smithflix Hub (TMDB data via Seerr), usually one that isn't on the server yet. */
data class HubTitle(
val tmdbId: Int,
val type: String,
val title: String,
val year: Int?,
val overview: String,
val poster: String?,
val backdrop: String?,
val reason: String,
val status: String,
) {
val isRequested get() = status in setOf("requested", "processing", "partial", "available")
val label get() = if (year != null) "$title ($year)" else title
}
data class HubResult(val ok: Boolean, val message: String, val status: String? = null)
/**
* Talks to the Smithflix Hub (app.smithflix.us). The hub checks the person's Jellyfin sign-in and
* then talks to Seerr on their behalf; the app never holds the Seerr key.
*/
object SmithflixHub : KoinComponent {
private val api by inject<ApiClient>()
private fun open(path: String, method: String = "GET"): HttpURLConnection {
val conn = URL(BuildConfig.SMITHFLIX_HUB_URL.trimEnd('/') + path).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = 10_000
conn.readTimeout = 30_000
conn.setRequestProperty("Accept", "application/json")
api.accessToken?.let { conn.setRequestProperty("Authorization", "MediaBrowser Token=\"$it\"") }
return conn
}
private fun read(conn: HttpURLConnection): Pair<Int, JSONObject> {
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
return code to (runCatching { JSONObject(text) }.getOrNull() ?: JSONObject())
}
private fun parse(arr: JSONArray?): List<HubTitle> {
if (arr == null) return emptyList()
return (0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
HubTitle(
tmdbId = o.optInt("tmdbId"),
type = o.optString("type", "movie"),
title = o.optString("title"),
year = o.optInt("year").takeIf { it > 0 },
overview = o.optString("overview"),
poster = o.optString("poster").takeIf { it.isNotBlank() && it != "null" },
backdrop = o.optString("backdrop").takeIf { it.isNotBlank() && it != "null" },
reason = o.optString("reason").takeIf { it != "null" }.orEmpty(),
status = o.optString("status", "none"),
)
}.filter { it.tmdbId > 0 && it.title.isNotBlank() }
}
/** Tonight's AI picks of titles that aren't on Smithflix yet. Empty if anything goes wrong. */
suspend fun suggestions(): List<HubTitle> = withContext(Dispatchers.IO) {
runCatching {
val (code, body) = read(open("/api/suggestions"))
if (code == 200) parse(body.optJSONArray("results")) else emptyList()
}.onFailure { Timber.w(it, "Smithflix Hub: suggestions failed") }.getOrDefault(emptyList())
}
/** Movies and shows matching a search, excluding ones already on Smithflix. */
suspend fun search(query: String): List<HubTitle> = withContext(Dispatchers.IO) {
runCatching {
val (code, body) = read(open("/api/search?q=" + URLEncoder.encode(query, "UTF-8")))
if (code == 200) parse(body.optJSONArray("results")).filter { it.status != "available" && it.status != "partial" }
else emptyList()
}.onFailure { Timber.w(it, "Smithflix Hub: search failed") }.getOrDefault(emptyList())
}
suspend fun request(title: HubTitle): HubResult = withContext(Dispatchers.IO) {
runCatching {
val conn = open("/api/request", "POST")
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.outputStream.use {
it.write(JSONObject().put("type", title.type).put("tmdbId", title.tmdbId).toString().toByteArray())
}
val (code, body) = read(conn)
if (code in 200..299) HubResult(true, body.optString("message", "Requested!"), body.optString("status"))
else HubResult(false, body.optString("error", "The request didn't go through."))
}.getOrElse {
Timber.w(it, "Smithflix Hub: request failed")
HubResult(false, "Couldn't reach Smithflix requests. Check your connection and try again.")
}
}
}

View file

@ -0,0 +1,156 @@
package org.jellyfin.androidtv.smithflix
import android.app.AlertDialog
import android.content.Context
import android.content.ContextWrapper
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import androidx.leanback.widget.ArrayObjectAdapter
import androidx.leanback.widget.Presenter
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import androidx.compose.ui.platform.findViewTreeCompositionContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.ui.base.SmithflixColors
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.composable.AsyncImage
/** Poster card for a title that isn't on Smithflix yet. */
class HubTitlePresenter(private val heightDp: Int = 187) : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val item = MutableStateFlow<HubTitle?>(null)
val focused = MutableStateFlow(false)
val view = ComposeView(parent.context).apply {
setParentCompositionContext(parent.findViewTreeCompositionContext())
setViewTreeLifecycleOwner(parent.findViewTreeLifecycleOwner())
setViewTreeSavedStateRegistryOwner(parent.findViewTreeSavedStateRegistryOwner())
isFocusable = true
isFocusableInTouchMode = true
setOnFocusChangeListener { _, f -> focused.value = f }
setContent {
val t by item.collectAsState()
val f by focused.collectAsState()
t?.let { HubTitleCard(it, f, heightDp) }
}
}
return Holder(view, item)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any?) {
(viewHolder as Holder).item.value = item as? HubTitle
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
(viewHolder as Holder).item.value = null
}
private class Holder(view: ComposeView, val item: MutableStateFlow<HubTitle?>) : ViewHolder(view)
}
@androidx.compose.runtime.Composable
private fun HubTitleCard(t: HubTitle, focused: Boolean, heightDp: Int) {
val w = (heightDp * 2 / 3).dp
val shape = RoundedCornerShape(8.dp)
Column(modifier = Modifier.width(w).padding(bottom = 6.dp)) {
Box(
modifier = Modifier
.width(w)
.height(heightDp.dp)
.scale(if (focused) 1.05f else 1f)
.clip(shape)
.background(SmithflixColors.GlassPanel)
.then(if (focused) Modifier.border(3.dp, SmithflixColors.FocusOutline, shape) else Modifier),
) {
if (t.poster != null) {
AsyncImage(modifier = Modifier.fillMaxSize(), url = t.poster, aspectRatio = 2f / 3f,
scaleType = ImageView.ScaleType.CENTER_CROP)
} else {
Text(t.title, modifier = Modifier.align(Alignment.Center).padding(10.dp), color = Color.White,
fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
val badge = if (t.isRequested) "REQUESTED" else "REQUEST"
Text(
badge,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (t.isRequested) Color(0xCC1E7A4A) else Color(0xCC0A63F2))
.padding(horizontal = 6.dp, vertical = 2.dp),
color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Bold,
)
}
Text(t.title, modifier = Modifier.padding(top = 6.dp), color = Color.White, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(t.year?.toString(), if (t.type == "tv") "Series" else "Movie").joinToString(" · "),
color = Color(0xFF9AA6BF), fontSize = 11.sp, maxLines = 1,
)
}
}
/** "Request this?" popup. Updates the card in [adapter] once requested. */
object SmithflixRequestDialog {
private fun Context.activity(): FragmentActivity? {
var c: Context? = this
while (c is ContextWrapper) {
if (c is FragmentActivity) return c
c = c.baseContext
}
return null
}
fun show(context: Context, title: HubTitle, adapter: ArrayObjectAdapter? = null) {
val activity = context.activity() ?: return
val message = buildString {
if (title.reason.isNotBlank()) append("Why it's here: ").append(title.reason).append("\n\n")
append(title.overview.ifBlank { "No description available." })
if (title.isRequested) append("\n\nThis one has already been requested.")
}
val builder = AlertDialog.Builder(activity)
.setTitle(title.label + if (title.type == "tv") " · Series" else "")
.setMessage(message)
.setNegativeButton("Close", null)
if (!title.isRequested) {
builder.setPositiveButton(if (title.type == "tv") "Request series" else "Request movie") { _, _ ->
activity.lifecycleScope.launch {
val result = SmithflixHub.request(title)
Toast.makeText(activity, result.message, Toast.LENGTH_LONG).show()
if (result.ok && adapter != null) {
val i = adapter.indexOf(title)
if (i >= 0) adapter.replace(i, title.copy(status = "requested"))
}
}
}
}
builder.show().getButton(AlertDialog.BUTTON_POSITIVE)?.requestFocus()
}
}

View file

@ -0,0 +1,37 @@
package org.jellyfin.androidtv.smithflix
import android.content.Context
import androidx.leanback.widget.ArrayObjectAdapter
import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow
import androidx.leanback.widget.OnItemViewClickedListener
import androidx.leanback.widget.Presenter
import androidx.leanback.widget.Row
import androidx.leanback.widget.RowPresenter
import org.jellyfin.androidtv.ui.home.HomeFragmentRow
import org.jellyfin.androidtv.ui.presentation.CardPresenter
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
/** Home row: the AI's picks of titles that aren't on Smithflix yet, each one requestable. */
class SmithflixSuggestionsRow(private val items: List<HubTitle>) : HomeFragmentRow, OnItemViewClickedListener {
private val adapter = ArrayObjectAdapter(HubTitlePresenter()).apply { addAll(0, items) }
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
if (items.isEmpty()) return
rowsAdapter.add(ListRow(HeaderItem(TITLE), adapter))
}
override fun onItemClicked(itemViewHolder: Presenter.ViewHolder?, item: Any?, rowViewHolder: RowPresenter.ViewHolder?, row: Row?) {
if (item !is HubTitle) return
val context = itemViewHolder?.view?.context ?: return
SmithflixRequestDialog.show(context, item, adapter)
}
companion object {
const val TITLE = "Not on Smithflix yet"
/** Loads tonight's suggestions; returns null when there are none (or the hub isn't set up). */
suspend fun load(): SmithflixSuggestionsRow? =
SmithflixHub.suggestions().takeIf { it.isNotEmpty() }?.let { SmithflixSuggestionsRow(it) }
}
}

View file

@ -83,6 +83,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
private var currentItem: BaseRowItem? = null
private var currentRow: ListRow? = null
private var justLoaded = true
private var suggestionsRow: org.jellyfin.androidtv.smithflix.SmithflixSuggestionsRow? = null
// Special rows
private val notificationsRow by lazy { NotificationsHomeFragmentRow(lifecycleScope, notificationsRepository) }
@ -124,7 +125,14 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(HomeFragmentViewsRow(small = false))
HomeSectionType.LIBRARY_BUTTONS -> rows.add(HomeFragmentViewsRow(small = true))
HomeSectionType.RESUME -> rows.add(helper.loadResumeVideo())
HomeSectionType.AI_PICKS -> rows.addAll(helper.loadAiRows(api, currentUser.id))
HomeSectionType.AI_PICKS -> {
rows.addAll(helper.loadAiRows(api, currentUser.id))
// Smithflix: AI picks of titles that aren't on the server yet, with a Request button
org.jellyfin.androidtv.smithflix.SmithflixSuggestionsRow.load()?.let {
suggestionsRow = it
rows.add(it)
}
}
HomeSectionType.RESUME_AUDIO -> rows.add(helper.loadResumeAudio())
HomeSectionType.RESUME_BOOK -> Unit // Books are not (yet) supported
HomeSectionType.ACTIVE_RECORDINGS -> rows.add(helper.loadLatestLiveTvRecordings())
@ -163,6 +171,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
onItemViewClickedListener = CompositeClickedListener().apply {
registerListener(ItemViewClickedListener())
registerListener(notificationsRow::onItemClicked)
registerListener { vh, item, rvh, row -> suggestionsRow?.onItemClicked(vh, item, rvh, row) }
}
onItemViewSelectedListener = CompositeSelectedListener().apply {

View file

@ -64,6 +64,9 @@ class SearchFragment : Fragment() {
textInputFocusRequester.requestFocus()
}
launch {
viewModel.requestableFlow.collect { titles -> searchFragmentDelegate.showRequestable(titles) }
}
viewModel.searchResultsFlow.collect { results ->
searchFragmentDelegate.showResults(results)
}

View file

@ -22,8 +22,22 @@ class SearchFragmentDelegate(
) {
val rowsAdapter = MutableObjectAdapter<Row>(CustomListRowPresenter())
// Smithflix: "Request on Smithflix" row for titles that aren't on the server yet
private val requestAdapter = androidx.leanback.widget.ArrayObjectAdapter(org.jellyfin.androidtv.smithflix.HubTitlePresenter(150))
private val requestRow = ListRow(HeaderItem("Request on Smithflix"), requestAdapter)
fun showRequestable(titles: List<org.jellyfin.androidtv.smithflix.HubTitle>) {
requestAdapter.clear()
requestAdapter.addAll(0, titles)
val index = rowsAdapter.indexOf(requestRow)
if (titles.isEmpty() && index >= 0) rowsAdapter.removeAt(index)
if (titles.isNotEmpty() && index < 0) rowsAdapter.add(requestRow)
}
fun showResults(searchResultGroups: Collection<SearchResultGroup>) {
rowsAdapter.clear()
// the request row comes back (at the end) once the hub answers for this search
if (requestAdapter.size() > 0) rowsAdapter.add(requestRow)
val adapters = mutableListOf<ItemRowAdapter>()
for ((labelRes, baseItems) in searchResultGroups) {
val adapter = ItemRowAdapter(
@ -41,6 +55,10 @@ class SearchFragmentDelegate(
}
val onItemViewClickedListener = OnItemViewClickedListener { _, item, _, row ->
if (item is org.jellyfin.androidtv.smithflix.HubTitle) {
org.jellyfin.androidtv.smithflix.SmithflixRequestDialog.show(context, item, requestAdapter)
return@OnItemViewClickedListener
}
if (item !is BaseRowItem) return@OnItemViewClickedListener
row as ListRow
val adapter = row.adapter as ItemRowAdapter
@ -48,7 +66,7 @@ class SearchFragmentDelegate(
}
val onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
val baseItem = item?.let { (item as BaseRowItem).baseItem }
val baseItem = (item as? BaseRowItem)?.baseItem
if (baseItem != null) {
backgroundService.setBackground(baseItem)
} else {

View file

@ -45,6 +45,10 @@ class SearchViewModel(
private val _searchResultsFlow = MutableStateFlow<Collection<SearchResultGroup>>(emptyList())
val searchResultsFlow = _searchResultsFlow.asStateFlow()
// Smithflix: titles that aren't on the server yet, from the Smithflix Hub (Seerr), for the Request row
private val _requestableFlow = MutableStateFlow<List<org.jellyfin.androidtv.smithflix.HubTitle>>(emptyList())
val requestableFlow = _requestableFlow.asStateFlow()
fun searchImmediately(query: String) = searchDebounced(query, 0.milliseconds)
fun searchDebounced(query: String, debounce: Duration = debounceDuration): Boolean {
@ -56,12 +60,16 @@ class SearchViewModel(
if (trimmed.isBlank()) {
_searchResultsFlow.value = emptyList()
_requestableFlow.value = emptyList()
return true
}
searchJob = viewModelScope.launch {
delay(debounce)
_requestableFlow.value = emptyList()
launch { _requestableFlow.value = org.jellyfin.androidtv.smithflix.SmithflixHub.search(trimmed) }
_searchResultsFlow.value = groups.map { (stringRes, itemKinds) ->
async {
val result = searchRepository.search(trimmed, itemKinds)