0.3.27 - Omni search fixes, Services in genre browser..

This commit is contained in:
ponzischeme89
2026-08-26 08:42:56 +12:00
parent 919a12f8d5
commit 9d11bb7282
26 changed files with 2202 additions and 590 deletions
+70 -5
View File
@@ -703,11 +703,76 @@ of a copy, wrong the moment Tracearr corrects a session. It is read two ways:
6×6 on-screen keyboard on the left, with `SearchGenres` above `SearchResults` on the right.
The results list updates as you type and uses the same shared `MediaResultCard` presentation
as My Requests; request status and actions remain feature-owned slots around that shared row.
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)`
`trim``distinctUntilChanged``collectLatest { repository.search(it) }` — and
`collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow
response for a prefix can never overwrite the results for what was typed after it.
Searching starts at two characters (`shouldSearch`); one letter matches half a library.
**It is two pipelines, and the whole design is that they never wait for each other.**
`SearchViewModel` runs a *library* search on `debounce(250)``trim`
`distinctUntilChanged``collectLatest { repository.search(it) }`, where `collectLatest`
is the load-bearing part: it cancels the in-flight request, so a slow response for a prefix
can never overwrite the results for what was typed after it. Searching starts at two
characters (`shouldSearch`); one letter matches half a library. Beside it runs *external
discovery* — `GET /v1/search/discover`, Sonarr and Radarr — which used to ride the same
per-keystroke stream and therefore sent nine lookups to two of somebody else's services on
the way to typing one title. Things to preserve:
- **Discovery is deliberately hard to trigger, in four independent ways.** A longer
debounce (`DISCOVERY_DEBOUNCE_MS`, 700ms, several times the library's — at 250ms most of
the gaps between presses on a remote look like the end of a word), a three-character
floor (`MIN_DISCOVERY_QUERY_LENGTH`, where the library's is two), a `distinctUntilChanged`
over the **normalised** query, and a session cache. Any one of them removed puts the
*arrs back on the keystroke path.
- **`normaliseDiscoveryQuery` exists twice** — `data/SearchDiscovery.kt` and the gateway's
`normaliseDiscoveryQuery` — and the two must agree, because it is what the server's
cooldown is keyed on: "Disclosure", "disclosure" and "disclosure " have to reach the
cache rather than Radarr.
- **Pressing Search, and finishing a voice query, skip the debounce.** They are somebody
saying they have finished typing, which is the one thing a timer is guessing at — so the
keyboard's Search key and `onVoiceResult` both go through `submitQuery`, which runs the
library search immediately and emits into `submissions`, the flow merged past the
debounce. A completed transcription is a submitted query, never a keystroke.
- **The cooldown is the server's, keyed on `cache.MetadataKey`.** What Radarr knows about a
title is a fact about the world and the household's catalogue with nothing derived from
whoever typed it, so one lookup answers every television for `discoveryCacheTTL` (10
minutes; `discoveryEmptyTTL`, 2, for a lookup that found nothing, because nothing is the
answer for a typo and typos get corrected). `cachedRead` puts a singleflight in front of
it, so two sets missing the same key in the same second make one lookup. Whether *this*
viewer may press Request is stamped on the way out (`applyRequestable`) rather than baked
into what the household shares — the `MetadataKey` rule, exactly.
- **`state.discovery` is kept apart from `state.results`.** That separation is what makes
every promise below it true: the library list cannot be reordered by a late answer,
nothing on screen moves when the second section arrives, and it is simply appended under
whatever the viewer is already looking at. Merging the two lists is how a focused card
comes to jump.
- **A title is never offered twice.** The gateway drops anything its own catalogue answers
for (`discoverSonarr` / `discoverRadarr` skip `inLibrary`); `discoverySection` on the
television is the other half, for a title Emby holds that the import has not caught up
with. Both match on a stable id first (`searchIdentity`: Tmdb, Tvdb, Imdb) and fall back
to title-and-type, which is deliberately the weakest rule rather than the first tried.
`/v1/search` asks Emby for `ProviderIds` for exactly this — hence the `search:v2:` cache
key.
- **Sonarr or Radarr being down is never an error the viewer sees.** A failed leg leaves
the library section reading exactly as it does when everything works, and the section
says "Additional results unavailable" or says nothing. `partial` on the response is what
distinguishes "nothing else exists" from "we could not ask"; only the second is worth a
line.
- **A requestable result opens a page, and does not send a request.** `ui/
RequestDetailsOverlay.kt` is that page — the same backdrop, poster, fact line and button
language every other detail page has, deliberately *not* the ordinary movie or series
layout, which is built around a file that exists and would carry Play, a resume bar and
watched state as four lies arranged as furniture. It is the judgement
`RadarrMovieDetailsOverlay` already makes for the schedule row, and the two are built
from the same pieces. It is stateless — poster and message are parameters — so
`SearchSectionsScreenshotTest` renders it with no gateway.
- **It replaced the request *mode*.** Search used to carry a Request chip in its heading
that swapped the results pane for a grid of `lookupMediaRequests` candidates. Discovery
is that feature, inline and progressive, and one thing reachable two ways is one thing
whose two entry points drift apart.
- **`ui/search/SearchTrace.kt` is where the time went** (`adb logcat -s MembySearch`),
debug-only like `StartupTrace`. The most useful line is `event=discovery_skipped`: an
external lookup that never happened is the feature *working*, and without a record of why
it is indistinguishable from one that silently failed. It logs the query's length rather
than the query.
- **The gateway's own line is DEBUG**, for the reason the search line is, and carries the
per-source counts, durations and cache verdicts plus how many duplicates the merge
removed.
`rankSearchResults` is a pure, stable sort that only lifts exact/prefix/word-boundary
title matches above the backend's own relevance order — it never re-sorts alphabetically,
and it keeps weak matches rather than showing an empty pane. A small access-ordered map
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.26"
val defaultVersionName = "0.3.27"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -964,7 +964,9 @@ class EmbyRepository internal constructor(
return searchRepository.search(term, limit)
}
fun searchProgress(term: String, limit: Int = 40) = searchRepository.searchProgress(term, limit)
/** External discovery, kept apart from [search] so a keystroke never waits on an *arr. */
suspend fun discover(term: String, limit: Int = DISCOVERY_LIMIT): DiscoveryResult =
searchRepository.discover(term, limit)
/**
* One page of a genre, dual-path like the search beside it.
@@ -990,6 +992,20 @@ class EmbyRepository internal constructor(
return searchRepository.browseGenre(genre, offset, limit, itemType)
}
/**
* One stable page filtered by streaming service shortcut (Apple TV+, Netflix, …), the
* [browseGenre] shape filtered on Emby's Studios field instead of Genres. See
* `ui/genre/GenreCategories.kt` for the service catalogue and its Emby spellings.
*/
suspend fun browseService(
service: String,
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String? = null,
): GenrePage {
return searchRepository.browseService(service, offset, limit, itemType)
}
/**
* Which genres this viewer actually watches, for the order of the Genres browser's rail.
*
@@ -0,0 +1,62 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
/**
* What Sonarr and Radarr had to say about a query, and whether they were both able to say
* it.
*
* [partial] is separate from an empty list on purpose: "nothing else exists" is an answer
* and "we could not ask" is not, and only the second is worth a quiet line under the
* results. Neither is ever an error the viewer sees an *arr being down must leave library
* search working exactly as it did.
*/
data class DiscoveryResult(
val items: List<BaseItem> = emptyList(),
val partial: Boolean = false,
/** Why nothing was asked at all, when nothing was. Diagnostics, never displayed. */
val skipped: String? = null,
)
/**
* The floor for asking somebody else's service.
*
* Two characters is the library's floor ([shouldSearch]); three is this one's. A two-letter
* query matches a large part of any catalogue, and unlike Emby the *arrs are not Memby's to
* make slow so the letters on the way to a word are never sent.
*/
const val MIN_DISCOVERY_QUERY_LENGTH = 3
/** How many external candidates are worth carrying. The library section is the main event. */
const val DISCOVERY_LIMIT = 20
/**
* The one rule for when two searches are the same search.
*
* It must agree with the gateway's `normaliseDiscoveryQuery`, because that is what the
* server-side cooldown is keyed on: "Disclosure", "disclosure" and "disclosure " are one
* question, and asking any of them after another has been asked should reach the cache
* rather than Radarr.
*/
fun normaliseDiscoveryQuery(query: String): String =
query.trim().lowercase().split(WHITESPACE).filter(String::isNotEmpty).joinToString(" ")
/** True when a query is worth putting to Sonarr and Radarr at all. */
fun shouldDiscover(query: String): Boolean =
normaliseDiscoveryQuery(query).length >= MIN_DISCOVERY_QUERY_LENGTH
/**
* Whether discovery results for [discovered] still plausibly answer what is now typed.
*
* They are kept while the viewer is still extending the same word, because taking a section
* away between two keystrokes is worse than briefly showing one that is a letter behind
* the newer answer is already on its way. Anything else is a different question and its
* results are dropped.
*/
fun discoveryStillRelevant(discovered: String, query: String): Boolean {
val current = normaliseDiscoveryQuery(query)
val previous = normaliseDiscoveryQuery(discovered)
return previous.isNotEmpty() && current.startsWith(previous)
}
private val WHITESPACE = Regex("\\s+")
@@ -69,20 +69,24 @@ class SearchRepository internal constructor(
)
}
/** Emits cumulative search snapshots as Emby, Sonarr and Radarr finish independently. */
fun searchProgress(term: String, limit: Int = 40): Flow<List<BaseItem>> = flow {
if (!ServerConfig.isGateway) { emit(search(term, limit)); return@flow }
requireGateway().searchStream(term.trim(), limit).use { body ->
val reader = body.charStream().buffered()
while (true) {
val line = reader.readLine() ?: break
if (line.isBlank()) continue
val snapshot = runCatching {
Json { ignoreUnknownKeys = true }.decodeFromString<GatewayItems>(line)
}.getOrNull()
snapshot?.let { emit(it.items) }
}
/**
* External discovery: what Sonarr and Radarr know that the library does not.
*
* A second request rather than part of [search], because those are somebody else's
* services and a keystroke must never wait on them. The gateway holds the cooldown
* one lookup answers the whole household for ten minutes so calling this after a
* pause in typing is cheap and calling it on a query already asked is free.
*
* The direct path has nobody to ask: with no gateway there is no Sonarr and no Radarr,
* and the honest answer is that discovery was skipped.
*/
suspend fun discover(term: String, limit: Int = DISCOVERY_LIMIT): DiscoveryResult {
val trimmed = term.trim()
if (!ServerConfig.isGateway || !shouldDiscover(trimmed)) {
return DiscoveryResult(skipped = if (ServerConfig.isGateway) "query_too_short" else "no_gateway")
}
val answer = requireGateway().searchDiscover(trimmed, limit)
return DiscoveryResult(items = answer.items, partial = answer.partial, skipped = answer.skipped)
}
/** One stable page filtered by genre; episodes are excluded deliberately. */
@@ -134,6 +138,56 @@ class SearchRepository internal constructor(
return GenrePage(items, offset, inferredTotal(offset, limit, items.size))
}
/**
* One stable page filtered by streaming service (Emby's Studios field); episodes are
* excluded, the [browseGenre] rule.
*
* Deliberately no keyword-search fallback for a gateway that predates the route: a
* service name is not a term anybody typed, and a text search for "Netflix" would
* answer with titles *about* Netflix rather than titles *on* it. An older gateway
* simply returns nothing, which the client reads as an empty shelf.
*/
suspend fun browseService(
service: String,
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String? = null,
): GenrePage {
val trimmed = service.trim()
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
val embyItemType = itemTypes(itemType)
if (ServerConfig.isGateway) {
runCatching {
requireGateway().serviceItems(
trimmed,
offset,
limit,
embyItemType.takeUnless { it == ALL_ITEM_TYPES },
)
}
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
return GenrePage(emptyList(), offset, 0)
}
}
val items = directItems(
params = mapOf(
"Studios" to trimmed,
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
return GenrePage(items, offset, inferredTotal(offset, limit, items.size))
}
/** One stable page of all films, all series, or both. */
suspend fun browseLibrary(
offset: Int = 0,
@@ -632,6 +632,22 @@ data class GatewayItems(
val items: List<BaseItem> = emptyList(),
)
/**
* `GET /v1/search/discover`.
*
* [partial] is why this is not simply a list: "there is nothing else to be had" and "we
* could not ask" are different answers, and only the second is worth a line under the
* results. [skipped] names why nothing was asked at all the query was too short, or the
* household runs neither *arr which is diagnostics rather than anything a viewer reads.
*/
@Serializable
data class GatewayDiscovery(
val query: String = "",
val items: List<BaseItem> = emptyList(),
val partial: Boolean = false,
val skipped: String? = null,
)
/**
* One page of `GET /v1/genres/{genre}/items` or `GET /v1/library/items`.
*
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayDiscovery
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.GatewayDevices
@@ -118,9 +119,19 @@ interface GatewayApi {
@GET("v1/search")
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
@Streaming
@GET("v1/search/stream")
suspend fun searchStream(@Query("q") term: String, @Query("limit") limit: Int): ResponseBody
/**
* External discovery: Sonarr and Radarr, and nothing the library can answer.
*
* Deliberately a second request rather than a field on [search]. Those two are
* somebody else's services on somebody else's network, and keeping them apart is the
* whole of the promise that a keystroke never waits on them see the gateway's
* `search_discover.go`.
*/
@GET("v1/search/discover")
suspend fun searchDiscover(
@Query("q") term: String,
@Query("limit") limit: Int,
): GatewayDiscovery
/**
* One page of a genre. A filter, not a query: the genre is the path rather than a term,
@@ -152,6 +163,19 @@ interface GatewayApi {
@Query("type") itemType: String,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
/**
* One page of a streaming service shortcut on the Genres page the same shelf as
* [genreItems], filtered on Emby's Studios field instead of Genres. See the gateway's
* `handleServiceItems`.
*/
@GET("v1/services/{service}/items")
suspend fun serviceItems(
@Path("service") service: String,
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("type") itemType: String? = null,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
/**
* One month of the TV calendar. An absent [month] is the household's present month,
* which is what a television asks for when the page opens.
@@ -62,12 +62,21 @@ internal fun MembyPlayButton(
onFocused: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
/**
* The mark on the primary action.
*
* A parameter rather than a second button, because the *surface* is the thing that has
* to be identical everywhere this is the one control on a page that says "this is
* what you came here to press". Only the glyph differs: a page whose primary action is
* Request must not wear a Play triangle, which is a claim about what pressing it does.
*/
icon: MembyIcon = MembyIcon.Play,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus")
PrimaryActionSurface(
label = label,
icon = MembyIcon.Play.mark,
icon = icon.mark,
focused = focused,
compact = compact,
modifier = modifier
@@ -337,7 +337,7 @@ private val RadarrPageMargin = 30.dp
private val RadarrOverviewLineHeight = 21.sp
@Composable
private fun RadarrPoster(url: String?, title: String) {
internal fun RadarrPoster(url: String?, title: String) {
val shape = RoundedCornerShape(MembyCardCorner)
Box(
Modifier
@@ -0,0 +1,248 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import kotlinx.coroutines.delay
/**
* A title Sonarr or Radarr knows about and the household does not have.
*
* Search now finds these, so pressing one had to lead somewhere. It deliberately leads
* *here* rather than straight to a request: a card in a list is a name and a year, and
* "Disclosure Day, 2027" is not enough to decide by. This is the page where somebody can
* read what it is before asking for it and it is a Memby page, with the same backdrop,
* poster, fact line and button language every other detail page has, rather than a form
* that suddenly reads as an administrative screen belonging to somebody else's software.
*
* It is deliberately *not* the ordinary movie or series page. That layout is built around a
* file that exists Play, a resume bar, watched state, tabs of cast and extras and
* carrying those over a title nobody can watch would be four lies arranged as furniture.
* This is the same judgement [RadarrMovieDetailsOverlay] makes for the schedule row, and the
* two pages are deliberately built from the same pieces.
*
* Stateless: everything it draws is a parameter, so it can be screenshotted with no gateway
* behind it and so the screen that owns the request owns its outcome.
*/
@Composable
fun RequestDetailsOverlay(
card: BaseItem,
/** The *arr's own poster. A parameter, so this page needs no repository to draw. */
posterUrl: String?,
onRequest: (GatewayRequestCandidate) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
/** In flight. The button says so rather than the page going blank. */
requesting: Boolean = false,
/** What came of the last attempt, if anything has been attempted. */
message: String? = null,
messageIsError: Boolean = false,
) {
val candidate = remember(card.id) { card.toRequestCandidate() }
val series = card.isSeries
val requestable = card.membyRequestable && candidate != null
val request = remember(card.id) { FocusRequester() }
val back = remember(card.id) { FocusRequester() }
// Request takes the remote when there is one — it is the only thing anybody opened this
// page to press. Back claims it otherwise, so a title that is already being fetched
// still has somewhere for the focus to be.
LaunchedEffect(card.id, requestable) {
delay(32L)
runCatching { if (requestable) request.requestFocus() else back.requestFocus() }
}
Box(modifier.fillMaxSize()) {
DetailBackdrop(card, Modifier.fillMaxSize())
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = RequestPageGutter, vertical = RequestPageMargin),
verticalAlignment = Alignment.CenterVertically,
) {
RadarrPoster(posterUrl, card.name)
Spacer(Modifier.width(38.dp))
// The actions are measured before the prose, never after it. A Column hands
// each unweighted child only what the ones above it left over, so a button row
// declared last takes whatever a long description did not want — which renders
// as a squeezed sliver rather than as a page that ran out of room. Everything
// that can honestly give way is inside the one weighted child. It is the same
// inversion the home hero and the detail hero make.
Column(Modifier.weight(1f)) {
RequestAvailabilityLine(card)
Spacer(Modifier.height(14.dp))
Text(
text = card.name,
color = Color.White,
fontSize = 36.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val facts = remember(card.id) {
listOfNotNull(
card.productionYear?.toString(),
if (series) "Series" else "Film",
card.genres.firstOrNull()?.takeIf(String::isNotBlank),
)
}
if (facts.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Text(
facts.joinToString(FactSeparator),
color = MembyMutedText,
fontSize = 14.sp,
maxLines = 1,
)
}
card.overview?.takeIf(String::isNotBlank)?.let { overview ->
Column(Modifier.weight(1f, fill = false).clipToBounds()) {
Spacer(Modifier.height(16.dp))
Text(
text = overview,
color = MembyMutedText,
fontSize = 15.sp,
lineHeight = RequestOverviewLineHeight,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth(0.86f)
// Prose gives way a whole line at a time. A Text handed
// less room still draws every line it was asked for, so
// the clip sits outside the snap or the dropped lines
// paint over the buttons underneath.
.clipToBounds()
.wholeLines(RequestOverviewLineHeight),
)
}
}
message?.let {
Spacer(Modifier.height(14.dp))
Text(
it,
color = if (messageIsError) MembyMutedText else MembyAccent,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(22.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
// Offered only when the gateway said this title can be asked for. A
// Request button that fails after being pressed is the one outcome this
// page must not produce, and the answer arrived with the card.
if (requestable && candidate != null) {
MembyPlayButton(
label = when {
requesting -> "Requesting…"
series -> "Request series"
else -> "Request movie"
},
onClick = { if (!requesting) onRequest(candidate) },
onFocused = {},
icon = MembyIcon.Add,
modifier = Modifier.focusRequester(request),
)
}
MembySecondaryButton(
label = "Back",
onClick = onClose,
modifier = Modifier.focusRequester(back),
)
}
}
}
}
}
/**
* Why this title is on a page with no Play button.
*
* The wording is the gateway's vocabulary rather than a sentence assembled here, and an
* unrecognised state says nothing at all instead of guessing a page that invents a reason
* is worse than one that simply offers the button.
*/
@Composable
private fun RequestAvailabilityLine(card: BaseItem) {
val label = when (card.membySearchState) {
"requestable" -> "NOT IN YOUR LIBRARY"
"requested", "processing" -> "ALREADY REQUESTED"
"pending" -> "REQUESTED — NOT OUT YET"
else -> return
}
Text(
label,
color = MembyQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
maxLines = 1,
)
}
/**
* The *arr candidate behind a search card.
*
* Keyed on the provider id rather than on the card's own id, because that id is Memby's
* (`radarr:12345`) and the request route speaks TMDb and TVDb. A card carrying neither
* cannot be requested, which is what withdraws the button rather than producing one that
* fails.
*/
internal fun BaseItem.toRequestCandidate(): GatewayRequestCandidate? {
val mediaType = when (membySource) {
"sonarr" -> "series"
"radarr" -> "movie"
else -> return null
}
val foreignId = when (mediaType) {
"series" -> providerIds["Tvdb"]
else -> providerIds["Tmdb"]
}?.toIntOrNull() ?: return null
return GatewayRequestCandidate(
mediaType = mediaType,
foreignId = foreignId,
title = name,
year = productionYear ?: 0,
overview = overview.orEmpty(),
posterUrl = membyPosterUrl.orEmpty(),
)
}
private val RequestPageGutter = DetailSideGutter
private val RequestPageMargin = 30.dp
/** The description's line box, shared by the text style and by [wholeLines]. */
private val RequestOverviewLineHeight = 21.sp
@@ -171,6 +171,13 @@ fun GenreBrowseScreen(
genreCategoryTabs(itemType).associate { it.id to FocusRequester() }
}
// Same shape, one per service icon. The Services strip is not itemType-specific, so
// this is stable for the life of the screen rather than keyed to it.
val serviceFocusRequesters = remember { serviceCategoryTabs().associate { it.id to FocusRequester() } }
/** Every requester the remote can land on across both strips, for a lookup by id. */
val allEntryFocusRequesters = railFocusRequesters + serviceFocusRequesters
// One grid state per genre, so returning to a genre returns to where it was left
// rather than to the top of it. Bounded by the catalogue, which is a fixed list.
val gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
@@ -184,8 +191,9 @@ fun GenreBrowseScreen(
var gridEntryIndex by remember(itemType) { mutableStateOf(0) }
val shownCategoryId = state.selectedCategoryId
val selectedCategory = remember(shownCategoryId, itemType) {
genreCategory(itemType, shownCategoryId)
val selectedCategory = remember(shownCategoryId, itemType, state.services) {
(state.categories + state.services).firstOrNull { it.id == shownCategoryId }
?: genreCategory(itemType, shownCategoryId)
}
val gridState = gridStates.getOrPut(shownCategoryId ?: initialCategoryId) { LazyGridState() }
@@ -211,7 +219,7 @@ fun GenreBrowseScreen(
* remember where the viewer was, and the genre they chose is the only honest answer.
*/
fun focusRail(): Boolean =
railFocusRequesters[activeCategoryId]?.let { runCatching { it.requestFocus() }.isSuccess }
allEntryFocusRequesters[activeCategoryId]?.let { runCatching { it.requestFocus() }.isSuccess }
?: runCatching { contentFocusRequester.requestFocus() }.isSuccess
LaunchedEffect(Unit) {
@@ -270,6 +278,29 @@ fun GenreBrowseScreen(
activeCategoryId = id
},
onEnterContent = ::enterGrid,
// Up out of the top genre only reaches the strip above it when there is
// one to reach — a screenshot test renders the rail with no services at
// all, and Up from there must still stand down rather than throw.
servicesFocusRequester = serviceFocusRequesters[state.services.firstOrNull()?.id],
topContent = state.services.takeIf { it.isNotEmpty() }?.let { services ->
{
ServicesRail(
services = services,
activeServiceId = activeCategoryId,
selectedServiceId = shownCategoryId,
navigationFocusRequester = navigationFocusRequester,
activeFocusRequester = contentFocusRequester,
itemFocusRequesters = serviceFocusRequesters,
onServiceFocused = { id ->
onContentFocused()
activeCategoryId = id
},
// Down out of the strip lands on the top genre, in whatever
// order personalisation put it in — never a fixed one.
downFocusRequester = railFocusRequesters[state.categories.firstOrNull()?.id],
)
}
},
)
BoxWithConstraints(
Modifier
@@ -484,6 +515,14 @@ internal fun GenreRail(
* capture that could only ever photograph the first would prove nothing.
*/
focusForCapture: String? = null,
/**
* Where Up out of the top genre lands the first Services icon, when there is a
* Services strip to reach. Null (a screenshot test, or a household with no service
* shortcuts configured) keeps the rail's original behaviour: Up is cancelled there.
*/
servicesFocusRequester: FocusRequester? = null,
/** The Services strip, rendered inside this rail's own column, background and hairline. */
topContent: (@Composable () -> Unit)? = null,
) {
val railState = rememberLazyListState()
// Only on arrival: once the viewer is in the rail, the lazy list scrolls itself as
@@ -530,6 +569,7 @@ internal fun GenreRail(
if (event.key == Key.DirectionRight) onEnterContent() else false
},
) {
topContent?.invoke()
Text(
"GENRES",
color = MembyQuietText,
@@ -572,7 +612,7 @@ internal fun GenreRail(
// item of the launcher's rail happens to be beside it.
left = navigationFocusRequester
up = if (index == 0) {
FocusRequester.Cancel
servicesFocusRequester ?: FocusRequester.Cancel
} else {
requesterFor(categories[index - 1].id)
}
@@ -693,6 +733,208 @@ private fun GenreRailItem(
}
}
/** Diameter of a service shortcut — the "small-button range" the design calls for. */
private val ServiceIconDiameter = 52.dp
/**
* The Services strip: a small label plus a horizontal row of circular shortcuts, sitting
* above the Genres rail with a gap wide enough to read as related but separate.
*
* It is a *filter*, not a destination selecting a service runs the same paged shelf the
* genre rail already draws, filtered on Emby's Studios field instead of Genres, and the
* viewer never leaves this page. See [BrowseFilterKind] and `server/internal/api/genres.go`.
*/
@Composable
internal fun ServicesRail(
services: List<GenreCategory>,
activeServiceId: String,
navigationFocusRequester: FocusRequester,
activeFocusRequester: FocusRequester,
onServiceFocused: (String) -> Unit,
/**
* One requester per service, owned by the screen for the same reason the genre rail's
* are: every way back into this strip names *which* icon it is returning to.
*/
itemFocusRequesters: Map<String, FocusRequester> = emptyMap(),
/** Where Down out of the strip lands — the top genre, in this viewer's own order. */
downFocusRequester: FocusRequester? = null,
/** The service whose shelf the grid is currently showing, drawn as the quiet wash. */
selectedServiceId: String? = null,
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
focusForCapture: String? = null,
) {
val ownedFocusRequesters = remember(services.map { it.id }) {
services.associate { it.id to FocusRequester() }
}
val requesterFor: (String) -> FocusRequester = { id ->
itemFocusRequesters[id] ?: ownedFocusRequesters.getValue(id)
}
Column(Modifier.fillMaxWidth()) {
Text(
"SERVICES",
color = MembyQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
modifier = Modifier.padding(start = 22.dp, top = 22.dp, bottom = 14.dp),
)
Row(
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 14.dp)
.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
services.forEachIndexed { index, service ->
val requester = requesterFor(service.id)
ServiceIconButton(
service = service,
active = service.id == selectedServiceId,
focusedForCapture = service.id == focusForCapture,
onFocused = { onServiceFocused(service.id) },
onClick = { onServiceFocused(service.id) },
modifier = Modifier
.focusRequester(requester)
.then(
if (service.id == activeServiceId) {
Modifier.focusRequester(activeFocusRequester)
} else {
Modifier
},
)
.focusProperties {
// Explicit, the genre rail's rule: vertical travel must never
// leave the strip by accident, and Left/Right walk the row.
up = FocusRequester.Cancel
down = downFocusRequester ?: FocusRequester.Default
left = if (index == 0) {
navigationFocusRequester
} else {
requesterFor(services[index - 1].id)
}
right = if (index == services.lastIndex) {
FocusRequester.Cancel
} else {
requesterFor(services[index + 1].id)
}
},
)
}
}
// The gap that reads as "related but separate" from the genre rail beneath it.
Spacer(Modifier.height(18.dp))
Box(
Modifier
.padding(horizontal = 22.dp)
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.06f)),
)
}
}
/**
* One circular service shortcut.
*
* Focus and selection are marked separately, the [GenreRailItem] stance: the icon under
* the remote is a bright white ring, brighter than anything else on the strip; the
* service whose shelf is on screen keeps a quieter ring once focus moves on. Neither
* state touches the tint inside the circle, which is the only place a service's own
* identity shows the ring stays unmistakably Memby regardless of which brand it sits on.
*/
@Composable
private fun ServiceIconButton(
service: GenreCategory,
active: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedForCapture: Boolean = false,
) {
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Browse ${service.label}",
modifier = modifier.size(ServiceIconDiameter),
) { hasFocus ->
val focused = hasFocus || focusedForCapture
val emphasis = remember { Animatable(if (focused) 1f else 0f) }
LaunchedEffect(focused) {
emphasis.animateTo(targetValue = if (focused) 1f else 0f, animationSpec = tween(140))
}
val tint = serviceTint(service.icon)
Box(
Modifier
.fillMaxSize()
.drawBehind {
val radius = size.minDimension / 2f
val centre = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f)
drawCircle(color = tint, radius = radius, center = centre)
drawCircle(
color = Color.Black.copy(alpha = 0.22f),
radius = radius,
center = centre,
)
// The ring is what says "Memby", never the brand tint underneath it —
// white always, brighter under focus than while merely active.
val ringAlpha = when {
focused -> 1f
active -> 0.5f
else -> 0f
}
if (ringAlpha > 0f) {
drawCircle(
color = Color.White.copy(alpha = ringAlpha),
radius = radius - (1.5f + 1.5f * emphasis.value).dp.toPx() / 2f,
center = centre,
style = androidx.compose.ui.graphics.drawscope.Stroke(
width = (2.dp + 1.dp * emphasis.value).toPx(),
),
)
}
},
contentAlignment = Alignment.Center,
) {
when (service.icon) {
GenreCategoryIcon.ALL_SERVICES -> Icon(
MembyIcon.Studio.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
else -> Text(
serviceMark(service.icon),
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
}
}
/**
* A quiet, brand-adjacent tint behind each mark enough to tell the shortcuts apart at a
* glance, deliberately not an attempt at exact brand colour. The ring above it, not this,
* is what carries the focus treatment.
*/
private fun serviceTint(icon: GenreCategoryIcon): Color = when (icon) {
GenreCategoryIcon.APPLE_TV -> Color(0xFF2B2B2E)
GenreCategoryIcon.NETFLIX -> Color(0xFF7A1420)
GenreCategoryIcon.DISNEY_PLUS -> Color(0xFF0E3F73)
else -> MembySurfaceRaised
}
/** The short mark drawn inside a service's circle. */
private fun serviceMark(icon: GenreCategoryIcon): String = when (icon) {
GenreCategoryIcon.APPLE_TV -> "tv+"
GenreCategoryIcon.NETFLIX -> "N"
GenreCategoryIcon.DISNEY_PLUS -> "D+"
else -> "?"
}
@Composable
private fun GenrePlaceholderGrid(
columns: Int,
@@ -19,6 +19,8 @@ private const val MAX_CACHED_CATEGORIES = 15
data class GenreBrowseUiState(
val categories: List<GenreCategory> = emptyList(),
/** The Services strip above [categories] — see [serviceCategoryTabs]. */
val services: List<GenreCategory> = emptyList(),
val selectedCategoryId: String? = null,
val items: List<BaseItem> = emptyList(),
/**
@@ -57,7 +59,14 @@ class GenreBrowseViewModel(
genreCategoryTabs(itemType),
repository.genreAffinitySnapshot(),
)
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
/** The Services strip. Not itemType-specific and not personalised — see [serviceCategoryTabs]. */
private val services = serviceCategoryTabs()
/** Both strips, for resolving a selection: an id may name either a genre or a service. */
private val selectable = categories + services
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories, services = services))
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null
@@ -81,7 +90,7 @@ class GenreBrowseViewModel(
private val warmJobs = mutableMapOf<String, Job>()
fun selectCategory(categoryId: String) {
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
val selected = selectable.firstOrNull { it.id == categoryId } ?: categories.first()
val current = state.value
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
val cached = categoryPages[selected.id]
@@ -130,7 +139,7 @@ class GenreBrowseViewModel(
fun loadMore() {
val current = state.value
val categoryId = current.selectedCategoryId ?: return
val category = categories.firstOrNull { it.id == categoryId } ?: return
val category = selectable.firstOrNull { it.id == categoryId } ?: return
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
// Direct cache query prevents offset races caused by rapid concurrent state updates.
@@ -143,7 +152,7 @@ class GenreBrowseViewModel(
fun retry() {
val current = state.value
val categoryId = current.selectedCategoryId ?: return
val category = categories.firstOrNull { it.id == categoryId } ?: return
val category = selectable.firstOrNull { it.id == categoryId } ?: return
val offset = categoryPages[categoryId]?.readOffset ?: current.readOffset
_state.update {
it.copy(
@@ -202,15 +211,23 @@ class GenreBrowseViewModel(
private suspend fun loadPage(category: GenreCategory, offset: Int) {
runCatching {
if (category.genres.isEmpty()) {
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
} else {
repository.browseGenre(
genre = category.filter,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
when {
category.genres.isEmpty() ->
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
category.kind == BrowseFilterKind.STUDIO ->
repository.browseService(
service = category.filter,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
else ->
repository.browseGenre(
genre = category.filter,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
}
}.onSuccess { page ->
val read = page.offset + page.items.size
@@ -13,11 +13,22 @@ import com.ponzischeme89.memby.data.model.UserItemData
* filter. The order is product design, not server data, and therefore never jumps around
* while home rows are arriving.
*/
/**
* Which Emby field a category's [GenreCategory.genres] list is matched against.
*
* [GENRE] is the ordinary rail; [STUDIO] is a Services shortcut (Apple TV+, Netflix,
* Disney+, ). The two are the same shelf shape a page of items filtered on an OR of
* Emby spellings so one data class and one paging pipeline serve both, and only the
* Emby query parameter differs (`Genres` vs `Studios`; see `server/internal/api/genres.go`).
*/
enum class BrowseFilterKind { GENRE, STUDIO }
data class GenreCategory(
val id: String,
val label: String,
val genres: List<String>,
val icon: GenreCategoryIcon,
val kind: BrowseFilterKind = BrowseFilterKind.GENRE,
) {
val filter: String get() = genres.joinToString("|")
}
@@ -40,6 +51,10 @@ enum class GenreCategoryIcon {
MUSIC,
SPORT,
REALITY,
ALL_SERVICES,
APPLE_TV,
NETFLIX,
DISNEY_PLUS,
}
const val ALL_MEDIA_CATEGORY_ID = "all"
@@ -148,6 +163,59 @@ private val realityCategory = GenreCategory(
GenreCategoryIcon.REALITY,
)
const val ALL_SERVICES_CATEGORY_ID = "service-all"
/**
* The streaming-service shortcut strip above the Genres rail.
*
* Product design, not server data the [GenreCategory] precedent, and for the same
* reason: the order must never jump around while a page is loading, and the list an
* operator sees in the Genres page has to be one somebody chose rather than whatever
* Emby's `Studios` field happens to contain today (which is every production company a
* metadata agent ever wrote, most of them meaningless to a viewer). A service with no
* matching titles is not hidden the empty-shelf message the genre rail already shows
* for a genre with nothing in it is the honest answer here too, and hiding it would need
* a request per service before the strip could even be drawn.
*
* Extending this list is the whole of "configurable, ordered, extended in future": add an
* entry with the service's own name and the Emby `Studios` spellings a real library
* carries for it (Prime Video, Max, Neon, the BBC), in the position it should appear.
*/
private val coreServiceCategories = listOf(
GenreCategory(
"service-apple-tv-plus",
"Apple TV+",
listOf("Apple TV+", "Apple TV Plus", "AppleTV+"),
GenreCategoryIcon.APPLE_TV,
BrowseFilterKind.STUDIO,
),
GenreCategory(
"service-netflix",
"Netflix",
listOf("Netflix"),
GenreCategoryIcon.NETFLIX,
BrowseFilterKind.STUDIO,
),
GenreCategory(
"service-disney-plus",
"Disney+",
listOf("Disney+", "Disney Plus", "DisneyPlus"),
GenreCategoryIcon.DISNEY_PLUS,
BrowseFilterKind.STUDIO,
),
)
private val allServicesCategory = GenreCategory(
id = ALL_SERVICES_CATEGORY_ID,
label = "All Services",
genres = emptyList(),
icon = GenreCategoryIcon.ALL_SERVICES,
kind = BrowseFilterKind.STUDIO,
)
/** The Services strip, with the "All Services"/cleared entry first — the genre rail's shape. */
fun serviceCategoryTabs(): List<GenreCategory> = listOf(allServicesCategory) + coreServiceCategories
fun genreCategories(itemType: String): List<GenreCategory> =
if (itemType.equals("Movie", ignoreCase = true)) coreGenreCategories
// Reality is a television shelf, so it is offered wherever series can appear.
@@ -86,6 +86,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.RequestDetailsOverlay
import com.ponzischeme89.memby.ui.search.components.SearchGenres
import com.ponzischeme89.memby.ui.search.components.SearchResults
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -189,8 +190,8 @@ fun SearchScreen(
val hasResultsTarget = when {
state.errorMessage != null && state.results.isEmpty() -> true
state.isDiscovery -> state.genreSuggestions.isNotEmpty()
else -> state.results.any { it.isMovie || it.isSeries } || state.requestCandidates.isNotEmpty() ||
(state.requestsAvailable && shouldSearch(state.query))
else -> state.results.any { it.isMovie || it.isSeries } || state.discovery.isNotEmpty() ||
state.discoveryLoading
}
LaunchedEffect(voiceAvailable) { runCatching { contentFocusRequester.requestFocus() } }
@@ -223,9 +224,15 @@ fun SearchScreen(
viewModel.clearGenre()
}
// A title nothing in the library answers for, opened to be read before it is asked
// for. It is held here rather than by the launcher because the request itself belongs
// to this screen's view model — and because Back out of it is one more level of this
// screen, not a step out of it.
var requestDetailsItem by remember { mutableStateOf<BaseItem?>(null) }
BackHandler {
when {
state.requestMode -> viewModel.hideRequests()
requestDetailsItem != null -> requestDetailsItem = null
// A genre is its own level, between discovery and leaving Search. Close that
// level first regardless of which card currently owns focus.
state.genre != null -> closeGenre()
@@ -238,8 +245,9 @@ fun SearchScreen(
}
}
Box(modifier.fillMaxSize()) {
Row(
modifier = modifier
modifier = Modifier
.fillMaxSize()
// A USB keyboard, or a phone remote app sending key events, feeds exactly the
// same query state as the on-screen keys. Only printable characters and
@@ -274,7 +282,10 @@ fun SearchScreen(
onCharacter = viewModel::appendToQuery,
onBackspace = viewModel::backspace,
onClear = viewModel::clearQuery,
onVoiceResult = viewModel::onQueryChanged,
// A completed transcription is a submitted query, not a keystroke: the
// debounce exists to guess at somebody having finished, and here they have.
onVoiceResult = viewModel::onVoiceResult,
onSubmit = viewModel::submitQuery,
voiceAvailable = voiceAvailable,
voiceFocusRequester = contentFocusRequester,
modifier = Modifier
@@ -294,7 +305,7 @@ fun SearchScreen(
resultsFocusRequester = searchResultsEntry,
resultsHaveFocusTarget = state.results.any { it.isMovie || it.isSeries } ||
(state.errorMessage != null && state.results.isEmpty()) ||
(state.requestsAvailable && shouldSearch(state.query)),
state.discovery.isNotEmpty(),
onFocused = {
focusInResults = true
onContentFocused()
@@ -303,42 +314,52 @@ fun SearchScreen(
)
Spacer(Modifier.height(14.dp))
if (state.requestMode) {
RequestOptions(
state = state,
resultsEntry = searchResultsEntry,
keyboardReturn = keyboardReturn,
onRequest = viewModel::request,
modifier = Modifier.weight(1f),
)
} else {
SearchResults(
state = state,
entryFocusRequester = searchResultsEntry,
genresFocusRequester = genresEntry,
genresPresent = state.genreSuggestions.isNotEmpty(),
keyboardReturnFocusRequester = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
artworkUrlFor = { item ->
repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500)
},
onItemFocused = { item ->
focusInResults = true
onContentFocused()
onItemFocused(item)
},
onItemSelected = onItemSelected,
onRequest = viewModel::request,
onRetry = viewModel::retry,
onShowRequests = viewModel::showRequests,
onLoadMore = viewModel::loadMore,
voiceAvailable = voiceAvailable,
modifier = Modifier.weight(1f),
)
}
SearchResults(
state = state,
entryFocusRequester = searchResultsEntry,
genresFocusRequester = genresEntry,
genresPresent = state.genreSuggestions.isNotEmpty(),
keyboardReturnFocusRequester = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
artworkUrlFor = { item ->
repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500)
},
onItemFocused = { item ->
focusInResults = true
onContentFocused()
onItemFocused(item)
},
onItemSelected = { item ->
// A library result opens the ordinary Memby page, exactly as it did
// before there was a second section. Only a title the household does
// not have takes the other route, and it opens a page rather than
// sending a request: a name and a year is not enough to decide by.
if (item.membyPlayable || item.membySource == null) {
onItemSelected(item)
} else {
requestDetailsItem = item
}
},
onRetry = viewModel::retry,
onLoadMore = viewModel::loadMore,
voiceAvailable = voiceAvailable,
modifier = Modifier.weight(1f),
)
}
}
requestDetailsItem?.let { subject ->
RequestDetailsOverlay(
card = subject,
posterUrl = repository.primaryUrl(subject, 500),
onRequest = viewModel::request,
onClose = { requestDetailsItem = null },
requesting = state.requestingCandidateKey != null,
message = state.requestMessage,
messageIsError = state.requestMessageIsError,
)
}
}
}
@Composable
@@ -355,6 +376,7 @@ private fun SearchPane(
onBackspace: () -> Unit,
onClear: () -> Unit,
onVoiceResult: (String) -> Unit,
onSubmit: () -> Unit,
voiceAvailable: Boolean,
voiceFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
@@ -393,6 +415,11 @@ private fun SearchPane(
onBackspace = onBackspace,
onClear = onClear,
upTarget = if (voiceAvailable) voiceFocusRequester else null,
// The library is searched as you type, so this key is not what produces the
// results — it is how somebody says they have finished typing, which releases
// external discovery from its debounce a good half-second early.
onSearch = onSubmit,
searchEnabled = shouldSearch(state.query),
)
}
}
@@ -575,10 +602,10 @@ internal fun TvKeyboard(
/**
* What the Search key does, and whether there is one at all.
*
* Null means there is none, which is the Search tab: it queries a local index as you
* type, and a submit key there would be a control that does nothing the page has not
* already done. Requests passes one because its lookup is a live Radarr and Sonarr
* query see [RequestsViewModel][com.ponzischeme89.memby.ui.requests.RequestsViewModel].
* Null means there is none. Both tabs that draw this keyboard pass one, and on Search
* it does not produce the library results those are already on screen but releases
* external discovery from its debounce, which is the one thing a timer can only guess
* at. Requests passes one because its whole lookup is a live Radarr and Sonarr query.
*/
onSearch: (() -> Unit)? = null,
/** Whether enough has been typed for that key to do anything. */
@@ -841,274 +868,6 @@ private fun LoadingDot() {
)
}
@Composable
private fun RequestOptions(
state: SearchUiState,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
onRequest: (GatewayRequestCandidate) -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(42.dp)
.clip(RoundedCornerShape(11.dp))
.background(Accent.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Icon(MembyIcon.Add.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(
"Search for movies and series, then choose the exact title you want added.",
color = Muted,
fontSize = 13.sp,
maxLines = 2,
)
}
Text(
"${state.requestCandidates.take(6).size} MATCH${if (state.requestCandidates.size == 1) "" else "ES"}",
color = Accent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(16.dp))
if (state.requestCandidates.isNotEmpty()) LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f),
) {
itemsIndexed(
state.requestCandidates.take(6),
key = { _, candidate -> "${candidate.mediaType}:${candidate.foreignId}" },
) { index, candidate ->
RequestCandidateCard(
candidate = candidate,
busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}",
onRequest = { onRequest(candidate) },
previewArtwork = previewArtwork,
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { if (index % 2 == 0) left = keyboardReturn },
)
}
}
else {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
if (state.requestLookupLoading) "Finding movies and series…" else "No request matches found.",
color = Muted,
fontSize = 14.sp,
)
}
}
state.requestMessage?.let { message ->
val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(statusColor.copy(alpha = 0.12f))
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (state.requestMessageIsError) MembyIcon.Close.mark else MembyIcon.CheckCircle.mark,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(9.dp))
Text(message, color = KeyLabel, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
}
}
@Composable
private fun RequestCandidateCard(
candidate: GatewayRequestCandidate,
busy: Boolean,
onRequest: () -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES"
val actionLabel = when {
candidate.inLibrary -> "IN LIBRARY"
candidate.alreadyAdded -> "REQUESTED"
busy -> "REQUESTING…"
else -> "REQUEST"
}
FocusScaleContainer(
onFocused = {},
onClick = { if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) onRequest() },
contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
modifier = modifier,
) { focused ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(12.dp))
.background(
when {
focused -> MembyAccentMuted
candidate.inLibrary || candidate.alreadyAdded -> MembyAccentMuted
else -> MembyControlSurface
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = when {
focused -> Accent
candidate.inLibrary || candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
else -> Color.White.copy(alpha = 0.10f)
},
shape = RoundedCornerShape(12.dp),
)
.padding(9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(76.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(MembySurface),
contentAlignment = Alignment.Center,
) {
if (previewArtwork != null) {
Image(
bitmap = previewArtwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else if (candidate.posterUrl.isNotBlank()) {
AsyncImage(
model = candidate.posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
if (mediaLabel == "MOVIE") MembyIcon.Movie.mark else MembyIcon.LiveTv.mark,
contentDescription = null,
tint = Muted.copy(alpha = 0.65f),
modifier = Modifier.size(30.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(mediaLabel, color = Accent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
candidate.year.takeIf { it > 0 }?.let { year ->
Text("$year", color = Muted, fontSize = 11.sp)
}
}
Text(
candidate.title,
color = Heading,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (candidate.overview.isNotBlank()) {
Text(
candidate.overview,
color = Muted,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (candidate.inLibrary || candidate.alreadyAdded) {
Icon(MembyIcon.CheckCircle.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(5.dp))
}
Text(
actionLabel,
color = if (candidate.inLibrary || candidate.alreadyAdded || focused) Accent else KeyLabel,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) {
Text("", color = Accent, fontSize = 13.sp)
}
}
}
}
}
}
/** Stable, data-free fixture used by the screenshot test and design reviews. */
@Composable
internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) {
val firstCardFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstCardFocus.requestFocus() } }
Box(
Modifier
.fillMaxSize()
.background(MembySurface)
.padding(28.dp),
) {
RequestOptions(
state = SearchUiState(
query = "the last voyage",
hasSearched = true,
requestCandidates = listOf(
GatewayRequestCandidate(
mediaType = "movie", foreignId = 101, title = "The Last Voyage",
year = 2026,
overview = "A lone cartographer follows a signal beyond the edge of every known map.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 102, title = "Voyagers",
year = 2024,
overview = "Six strangers wake aboard a ship already halfway to another world.",
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 103, title = "The Long Way Home",
year = 2022,
overview = "A rescue pilot gets one final chance to cross the storm.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 104, title = "Beyond the Horizon",
year = 2025,
overview = "An observatory discovers that tomorrow is broadcasting back.",
alreadyAdded = true,
),
),
),
resultsEntry = firstCardFocus,
keyboardReturn = remember { FocusRequester() },
onRequest = {},
previewArtwork = previewArtwork,
)
}
}
private const val KEYBOARD_COLUMNS = 6
private const val ACTION_ROW_INDEX = 36
@@ -0,0 +1,66 @@
package com.ponzischeme89.memby.ui.search
import android.util.Log
import com.ponzischeme89.memby.BuildConfig
/**
* Where a search's time went.
*
* "Search feels slow" is no more actionable than "playback feels slow" was, and this is the
* same answer `PlaybackTrace` gives: a line per stage, carrying the numbers that separate
* the four things that could be responsible. `local=` is Memby and Emby, `discovery=` is
* Sonarr and Radarr, `cache=hit` is neither, and a discovery line that never appears at all
* says the debounce and the floor did their job.
*
* Debug-only, like `PerformanceMonitor` and `StartupTrace`: a television has no log anybody
* reads, so in production these would be pure cost. Every entry point returns before
* formatting anything on a release build.
*
* Read it with `adb logcat -s MembySearch`.
*/
internal object SearchTrace {
private const val TAG = "MembySearch"
fun local(searchId: Long, query: String, results: Int, cacheHit: Boolean, elapsedMs: Long) {
write("event=local id=$searchId ${term(query)} results=$results ${cache(cacheHit)} ms=$elapsedMs")
}
fun discovery(
searchId: Long,
query: String,
results: Int,
cacheHit: Boolean,
partial: Boolean,
elapsedMs: Long,
) {
write(
"event=discovery id=$searchId ${term(query)} results=$results ${cache(cacheHit)} " +
"partial=$partial ms=$elapsedMs",
)
}
/**
* The most useful line here, and the one that is a *decision* rather than a
* measurement: an external lookup that never happened is the feature working, and
* without a record of why it is indistinguishable from one that silently failed.
*/
fun discoverySkipped(searchId: Long, query: String, reason: String) {
write("event=discovery_skipped id=$searchId ${term(query)} reason=$reason")
}
fun discoveryFailed(searchId: Long, query: String, error: Throwable) {
write("event=discovery_failed id=$searchId ${term(query)} error=${error.javaClass.simpleName}")
}
private fun term(query: String) = "q=${query.length}c"
private fun cache(hit: Boolean) = if (hit) "cache=hit" else "cache=miss"
private fun write(line: String) {
if (!BuildConfig.DEBUG) return
// A trace must never be a reason something fails, and this file is also reached
// from plain JUnit tests where android.util.Log is not mocked.
runCatching { Log.d(TAG, line) }
}
}
@@ -3,16 +3,21 @@ package com.ponzischeme89.memby.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.DISCOVERY_LIMIT
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
import com.ponzischeme89.memby.data.discoveryStillRelevant
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.data.normaliseDiscoveryQuery
import com.ponzischeme89.memby.data.shouldDiscover
import com.ponzischeme89.memby.ui.distinctItems
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -20,12 +25,17 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
data class SearchUiState(
val query: String = "",
/**
* What the household already has. This section is the whole of the promise the search
* screen makes: it comes from Emby and Memby's own index, and nothing about external
* discovery is allowed to delay it, reorder it or take a card out of it.
*/
val results: List<BaseItem> = emptyList(),
/**
* The genre being browsed, or null when this pane is showing a search.
@@ -62,17 +72,41 @@ data class SearchUiState(
val hasSearched: Boolean = false,
val errorMessage: String? = null,
val pagingErrorMessage: String? = null,
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
/**
* Titles Sonarr and Radarr know about that the library does not the second section,
* which arrives whenever it arrives.
*
* Kept apart from [results] rather than merged into them, because that separation is
* what makes every promise below it true: the library list cannot be reordered by a
* late answer, nothing already on screen moves when these arrive, and the section is
* simply appended underneath whatever the viewer is already looking at.
*/
val discovery: List<BaseItem> = emptyList(),
/** The query [discovery] answers, which may be a keystroke or two behind [query]. */
val discoveryQuery: String = "",
/** An external lookup is in flight. Never blocks anything; worth one quiet line. */
val discoveryLoading: Boolean = false,
/**
* Sonarr or Radarr could not be asked. Deliberately distinct from an empty [discovery]:
* "there is nothing else to be had" is an answer and this is not, and neither is ever
* allowed to look like a failure of search itself.
*/
val discoveryUnavailable: Boolean = false,
/**
* The search this state belongs to. Bumped for every distinct query, so an answer can
* be checked against the question rather than against the order it happened to arrive
* in and so the diagnostics for one search can be read as one search.
*/
val searchId: Long = 0,
val requestsAvailable: Boolean = false,
val requestMode: Boolean = false,
val requestLookupLoading: Boolean = false,
val requestingCandidateKey: String? = null,
val requestMessage: String? = null,
val requestMessageIsError: Boolean = false,
) {
/** The query is long enough to search but nothing came back. */
/** The query is long enough to search but nothing came back, from either source. */
val isEmptyResult: Boolean
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
get() = hasSearched && !isLoading && !discoveryLoading && errorMessage == null &&
results.isEmpty() && discovery.isEmpty()
/** Nothing typed and no genre open: the pane shows discovery rather than results. */
val isDiscovery: Boolean
@@ -80,12 +114,20 @@ data class SearchUiState(
}
/**
* Instant search.
* Search, in two pipelines that never wait for each other.
*
* Typing feeds [onQueryChanged]; a debounce, a `distinctUntilChanged` and a
* `collectLatest` do the rest. The last of those is what makes rapid typing safe: it
* cancels the in-flight request when a newer query arrives, so a slow response for "bre"
* can never overwrite the results for "break".
* **The library is the fast one.** Typing feeds [onQueryChanged]; a short debounce, a
* `distinctUntilChanged` and a `collectLatest` do the rest. The last of those is what makes
* rapid typing safe: it cancels the in-flight request when a newer query arrives, so a slow
* response for "bre" can never overwrite the results for "break".
*
* **External discovery is the slow one, and it is deliberately hard to trigger.** Sonarr
* and Radarr are somebody else's services, so the letters on the way to a word are never
* sent: a longer debounce, a three-character floor, a normalised `distinctUntilChanged`
* (so "Disclosure" after "disclosure " is not a second lookup) and a session cache stand
* between typing and a request, with the gateway's own ten-minute cooldown behind all of
* them. Pressing Search, or finishing a voice query, skips the debounce those are
* somebody saying they have finished typing, which is the one thing a timer is guessing at.
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
@@ -97,6 +139,16 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
private val queryFlow = MutableStateFlow("")
/**
* A query somebody finished deliberately the keyboard's Search key, or a completed
* voice transcription. It bypasses the discovery debounce, which exists only to guess
* at this moment.
*
* `extraBufferCapacity` rather than a replay: a submission is an event, and one
* replayed to a collector that restarts would re-ask a question nobody asked twice.
*/
private val submissions = MutableSharedFlow<String>(extraBufferCapacity = 4)
/**
* Results for queries typed earlier in this session. Bounded and access-ordered, so
* backspacing through a word redraws instantly instead of re-querying every prefix.
@@ -107,8 +159,22 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
): Boolean = size > CACHE_ENTRIES
}
/**
* The same, for external discovery, keyed on the *normalised* query.
*
* The gateway holds the real cooldown this only saves the round trip. It is what makes
* leaving Search and coming back, or deleting and retyping a title, cost nothing at all.
*/
private val discoveryCache = object : LinkedHashMap<String, com.ponzischeme89.memby.data.DiscoveryResult>(16, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, com.ponzischeme89.memby.data.DiscoveryResult>?,
): Boolean = size > CACHE_ENTRIES
}
private var genreSuggestions: List<String> = emptyList()
private var searchId = 0L
/**
* The page in flight, so a viewer who leaves a genre or scrolls a second page while
* the first is still coming is never overtaken by an answer to a question they have
@@ -128,6 +194,19 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// same terms while keeping the repository's plain suspend signature.
.collectLatest { term -> runSearch(term) }
}
viewModelScope.launch {
merge(
// Typing, once it has stopped for long enough to look like a word.
queryFlow.debounce(DISCOVERY_DEBOUNCE_MS).map(::normaliseDiscoveryQuery),
// Somebody saying they have finished. No debounce: the timer was only ever
// guessing at exactly this.
submissions.map(::normaliseDiscoveryQuery),
)
// Normalised, so trailing space and a changed capital are not a second
// lookup. This is the same rule the gateway keys its cooldown on.
.distinctUntilChanged()
.collectLatest { term -> runDiscovery(term) }
}
refreshSuggestions()
}
@@ -144,12 +223,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genre = null,
isLoadingMore = false,
canLoadMore = false,
requestCandidates = emptyList(),
requestLookupLoading = false,
requestMessage = null,
requestMessageIsError = false,
pagingErrorMessage = null,
)
).withRelevantDiscovery()
}
queryFlow.value = query
}
@@ -158,6 +235,28 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
fun backspace() = onQueryChanged(state.value.query.dropLast(1))
/**
* The keyboard's Search key. Both halves run at once and neither waits for the other:
* the library answers from its own cache almost always, and discovery is released from
* its debounce because there is no longer anything to wait for.
*/
fun submitQuery() {
val term = state.value.query.trim()
if (!shouldSearch(term) || state.value.genre != null) return
viewModelScope.launch { runSearch(term) }
submissions.tryEmit(term)
}
/**
* A completed voice transcription, which is a submitted query and not a keystroke.
* Applying the typing debounce to it would delay a question somebody has already
* finished asking.
*/
fun onVoiceResult(spoken: String) {
onQueryChanged(spoken)
submitQuery()
}
fun clearQuery() {
// Clear immediately so results from a genre tile cannot remain visible while the
// debounced empty-query transition is pending.
@@ -166,13 +265,12 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(
query = "", results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestMode = false,
errorMessage = null,
pagingErrorMessage = null,
requestLookupLoading = false, requestMessage = null,
requestMessage = null,
requestMessageIsError = false, genre = null,
isLoadingMore = false, canLoadMore = false,
)
).withoutDiscovery()
}
queryFlow.value = ""
}
@@ -190,12 +288,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
query = "", genre = name, results = emptyList(), genreOffset = 0,
isLoading = true,
hasSearched = false, errorMessage = null, isLoadingMore = false,
canLoadMore = false, requestCandidates = emptyList(),
requestMode = false,
canLoadMore = false,
pagingErrorMessage = null,
requestLookupLoading = false, requestMessage = null,
requestMessage = null,
requestMessageIsError = false,
)
).withoutDiscovery()
}
// The typed query is dropped along with it, and the flow is told so a stale term
// cannot arrive from the debounce and overwrite the shelf that is opening.
@@ -250,7 +347,9 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
val term = current.query.trim()
if (!shouldSearch(term)) return
cache.remove(term)
discoveryCache.remove(normaliseDiscoveryQuery(term))
viewModelScope.launch { runSearch(term) }
submissions.tryEmit(term)
}
fun request(candidate: GatewayRequestCandidate) {
@@ -264,22 +363,21 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
viewModelScope.launch {
runCatching { repository.requestMedia(candidate) }
.onSuccess { title ->
// The card the viewer just pressed answers immediately rather than
// waiting for a re-search: the request has been accepted, and the
// section it lives in is the one they are still looking at.
discoveryCache.remove(normaliseDiscoveryQuery(queryAtRequest))
_state.update {
if (it.requestingCandidateKey != candidateKey) return@update it
val stillShowingRequest = it.query.trim() == queryAtRequest &&
it.genre == genreAtRequest
it.copy(
requestingCandidateKey = null,
results = if (stillShowingRequest) it.results.map { result ->
discovery = if (stillShowingRequest) it.discovery.map { result ->
if (searchRequestKey(result) == candidateKey) {
result.copy(membySearchState = "requested", membyRequestable = false)
} else result
} else it.results,
requestCandidates = if (stillShowingRequest) it.requestCandidates.map { option ->
if (option.mediaType == candidate.mediaType &&
option.foreignId == candidate.foreignId
) option.copy(alreadyAdded = true) else option
} else it.requestCandidates,
} else it.discovery,
requestMessage = if (stillShowingRequest) {
"${title.ifBlank { candidate.title }} was requested."
} else null,
@@ -302,23 +400,6 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
fun showRequests() {
val term = state.value.query.trim()
if (!shouldSearch(term)) return
_state.update {
it.copy(requestMode = true, requestCandidates = emptyList(), requestMessage = null,
requestMessageIsError = false)
}
viewModelScope.launch { loadRequestCandidates(term) }
}
fun hideRequests() {
_state.update {
it.copy(requestMode = false, requestCandidates = emptyList(), requestLookupLoading = false,
requestMessage = null, requestMessageIsError = false)
}
}
/**
* Genre chips for the empty state, taken from items the home screen already loaded.
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
@@ -399,6 +480,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* The library half. One request to one backend that answers out of its own index and
* Redis no *arr is involved and none can make this slow.
*/
private suspend fun runSearch(term: String) {
// A genre shelf is not a query, so the empty-query transition has nothing to say
// about it. Without this, opening a genre — which clears the query — would arrive
@@ -411,89 +496,111 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(
results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
errorMessage = null,
requestMessage = null,
requestMessageIsError = false,
)
).withoutDiscovery()
}
return
}
val id = ++searchId
_state.update { it.copy(searchId = id) }
cache[term]?.let { cached ->
SearchTrace.local(id, term, cached.size, cacheHit = true, elapsedMs = 0)
_state.update {
if (!it.answers(term)) return@update it
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
}
if (_state.value.requestMode) loadRequestCandidates(term)
return
}
// Previous results stay on screen underneath the spinner: a flash of empty pane
// between two letters reads as breakage, not as progress.
_state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching {
repository.searchProgress(term).collect { found ->
val startedAt = System.currentTimeMillis()
runCatching { repository.search(term) }
.onSuccess { found ->
// collectLatest cancels the preceding request, but cancellation is
// cooperative: an HTTP call that has already returned can still reach
// this non-suspending block. Drop it unless the field still asks the
// exact question that produced the answer.
if (_state.value.genre != null || _state.value.query.trim() != term) {
return@collect
// here. Drop it unless the field still asks the exact question that
// produced the answer.
if (!_state.value.answers(term)) return@onSuccess
// Keep the backend's relevance and personalisation order, but never let
// those softer signals bury the title the viewer typed exactly. This
// matters especially in the one-column list, where tenth place is ten rows
// away rather than the second grid row.
val ranked = rankSearchResults(term, found.distinctItems())
SearchTrace.local(id, term, ranked.size, cacheHit = false,
elapsedMs = System.currentTimeMillis() - startedAt)
cache[term] = ranked
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
}
// The gateway answers from the imported library and falls back to Emby
// before the first import has finished, so one title reaching the pane by
// both routes is a shape this search genuinely has. The result list is keyed by
// item id, where that is a crash rather than a repeated poster.
val items = mergeProgressiveSearchResults(_state.value.results, found).distinctItems()
// Keep the backend's relevance and personalisation order within each
// textual tier, but never let those softer signals bury the title the
// viewer typed exactly. This matters especially in the one-column list,
// where tenth place is ten rows away rather than the second grid row.
val ranked = if (_state.value.results.isEmpty()) rankSearchResults(term, items) else items
// Keep the loading signal alive after the first snapshot. The stream may still
// be waiting on Sonarr or Radarr, but the cards already on screen remain fully
// focusable and navigable while those additional results arrive.
_state.update { it.copy(results = ranked, isLoading = true, hasSearched = true, errorMessage = null) }
viewModelScope.launch { repository.recordSearch(term) }
}
}
.onFailure { error ->
// A cancelled search is the normal case while typing, not a failure.
if (error is kotlinx.coroutines.CancellationException) throw error
if (_state.value.genre != null || _state.value.query.trim() != term) {
return@onFailure
}
if (!_state.value.answers(term)) return@onFailure
_state.update {
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
}
}
if (_state.value.genre == null && _state.value.query.trim() == term) {
_state.update { it.copy(isLoading = false) }
cache[term] = _state.value.results
viewModelScope.launch { repository.recordSearch(term) }
if (_state.value.requestMode) loadRequestCandidates(term)
}
}
private suspend fun loadRequestCandidates(term: String) {
_state.update { it.copy(requestLookupLoading = true, requestMessage = null) }
val candidates = runCatching { repository.lookupMediaRequests(term) }
.getOrElse { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
if (_state.value.query.trim() == term) {
_state.update {
it.copy(
requestLookupLoading = false,
requestMessage = friendlyEmbyError(error),
requestMessageIsError = true,
)
}
}
emptyList()
}
if (_state.value.query.trim() == term) {
_state.update {
it.copy(requestCandidates = candidates, requestLookupLoading = false)
}
/**
* The external half. Everything about it is arranged so that it cannot slow the screen
* down or move anything already on it.
*/
private suspend fun runDiscovery(term: String) {
if (!repository.supportsMediaRequests) return
val current = state.value
if (current.genre != null || !shouldDiscover(term)) {
SearchTrace.discoverySkipped(current.searchId, term,
if (current.genre != null) "genre_open" else "query_too_short")
_state.update { it.withoutDiscovery() }
return
}
discoveryCache[term]?.let { cached ->
SearchTrace.discovery(current.searchId, term, cached.items.size,
cacheHit = true, partial = cached.partial, elapsedMs = 0)
_state.update { it.withDiscovery(term, cached.items, cached.partial) }
return
}
_state.update { it.copy(discoveryLoading = true, discoveryUnavailable = false) }
val startedAt = System.currentTimeMillis()
runCatching { repository.discover(term, DISCOVERY_LIMIT) }
.onSuccess { result ->
val items = result.items.distinctItems()
SearchTrace.discovery(state.value.searchId, term, items.size,
cacheHit = false, partial = result.partial,
elapsedMs = System.currentTimeMillis() - startedAt)
discoveryCache[term] = result.copy(items = items)
_state.update {
// A discovery answer is only ever applied to a query it still describes:
// the viewer may have typed on, in which case a newer lookup is already
// on its way and this one is history.
if (!discoveryStillRelevant(term, it.query) || it.genre != null) {
return@update it.copy(discoveryLoading = false)
}
it.withDiscovery(term, items, result.partial)
}
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
SearchTrace.discoveryFailed(state.value.searchId, term, error)
_state.update {
// Never an error message: an *arr being unreachable must leave library
// search reading exactly as it does when everything is working. The
// section says so quietly, or says nothing at all.
it.copy(
discoveryLoading = false,
discoveryUnavailable = discoveryStillRelevant(term, it.query) && it.genre == null,
)
}
}
}
private fun refreshSuggestions() {
@@ -502,32 +609,92 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
companion object {
const val DEBOUNCE_MS = 250L
/**
* How long typing must stop before Sonarr and Radarr are asked anything.
*
* Deliberately several times the library's own debounce. Typing "Disclosure Day" on
* a remote is a dozen keystrokes over several seconds, and at 250 ms most of the
* gaps between them are long enough to look like the end of a word which is how
* one title becomes nine external lookups. At this figure it is one, or two if
* somebody genuinely stops to think half way through.
*/
const val DISCOVERY_DEBOUNCE_MS = 700L
const val MIN_QUERY_LENGTH = 2
private const val CACHE_ENTRIES = 24
private const val MAX_GENRE_SUGGESTIONS = 6
}
}
/** Merges cumulative server snapshots without moving cards already visible to the viewer. */
private fun mergeProgressiveSearchResults(
current: List<BaseItem>, incoming: List<BaseItem>,
): List<BaseItem> {
val byKey = incoming.associateBy { searchIdentity(it) }
val used = HashSet<String>()
val stable = current.mapNotNull { old -> byKey[searchIdentity(old)]?.also { used += searchIdentity(old) } ?: old }
return stable + incoming.filter { used.add(searchIdentity(it)) }
}
/** Is this state still the answer to [term]? The one staleness rule the library half uses. */
private fun SearchUiState.answers(term: String): Boolean =
genre == null && query.trim() == term
private fun searchIdentity(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { item.providerIds[it]?.takeIf(String::isNotBlank)?.let { id -> "$it:$id" } }
private fun SearchUiState.withDiscovery(
term: String,
items: List<BaseItem>,
partial: Boolean,
): SearchUiState = copy(
discovery = items,
discoveryQuery = term,
discoveryLoading = false,
discoveryUnavailable = partial && items.isEmpty(),
)
private fun SearchUiState.withoutDiscovery(): SearchUiState =
copy(discovery = emptyList(), discoveryQuery = "", discoveryLoading = false,
discoveryUnavailable = false)
/**
* Keeps a discovery section only while it still plausibly answers what is typed.
*
* It survives somebody extending the word they were typing the newer answer is already on
* its way, and taking the section away between two keystrokes is worse than briefly showing
* one a letter behind. Anything else is a different question, and its results go.
*/
private fun SearchUiState.withRelevantDiscovery(): SearchUiState =
if (discoveryQuery.isNotEmpty() && !discoveryStillRelevant(discoveryQuery, query)) {
withoutDiscovery()
} else {
this
}
/**
* One title's identity across three systems.
*
* Emby, Sonarr and Radarr can each know about the same film, so the only safe way to
* collapse them into one card is a stable external id. The title fallback is what is left
* when nothing carries one, and it is deliberately the weakest rule here rather than the
* first one tried.
*/
internal fun searchIdentity(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { key -> item.providerIds[key]?.takeIf(String::isNotBlank)?.let { id -> "$key:$id" } }
.firstOrNull() ?: "${item.type}:${item.name.trim().lowercase()}"
private fun searchRequestKey(item: BaseItem): String? = when (item.membySource) {
internal fun searchRequestKey(item: BaseItem): String? = when (item.membySource) {
"sonarr" -> item.providerIds["Tvdb"]?.let { "series:$it" }
"radarr" -> item.providerIds["Tmdb"]?.let { "movie:$it" }
else -> null
}
/**
* The external section, with anything the library already answered for removed.
*
* The gateway drops what its own catalogue knows about, which covers nearly everything.
* This is the other half of that: a title Emby holds but the imported catalogue has not
* caught up with would otherwise appear once as playable and once as requestable, which is
* the single worst thing a progressive search can show.
*/
fun discoverySection(results: List<BaseItem>, discovery: List<BaseItem>): List<BaseItem> {
if (discovery.isEmpty()) return discovery
val known = results.mapTo(HashSet()) { searchIdentity(it) }
val titles = results.mapTo(HashSet()) { "${it.type}:${it.name.trim().lowercase()}" }
return discovery.filter { candidate ->
searchIdentity(candidate) !in known &&
"${candidate.type}:${candidate.name.trim().lowercase()}" !in titles
}
}
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
@@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
@@ -42,10 +43,12 @@ import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.toRequestCandidate
import com.ponzischeme89.memby.ui.components.media.MediaResultCard
import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton
import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph
import com.ponzischeme89.memby.ui.search.SearchUiState
import com.ponzischeme89.memby.ui.search.discoverySection
import com.ponzischeme89.memby.ui.search.shouldSearch
import com.ponzischeme89.memby.ui.requests.RequestCard
import com.ponzischeme89.memby.ui.requests.RequestActionRequest
@@ -58,7 +61,19 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.flow.distinctUntilChanged
/** Search-owned result state rendered through the same media row as My Requests. */
/**
* Search results, in two sections that arrive at different times.
*
* **AVAILABLE** is the library, and it is drawn the moment it answers. **AVAILABLE TO
* REQUEST** is Sonarr and Radarr, and it is appended underneath whenever it turns up
* underneath being the whole of the design. Nothing above it moves, nothing is re-sorted,
* the list is keyed by stable ids so no card is rebuilt, and a remote parked on a result
* stays exactly where it was. A section that arrives while somebody is choosing must never
* be a reason they lose their place.
*
* Both headings appear only when there is a second section to distinguish the first from.
* A single list under a heading saying AVAILABLE is a heading that answers nothing.
*/
@Composable
internal fun SearchResults(
state: SearchUiState,
@@ -71,14 +86,18 @@ internal fun SearchResults(
artworkUrlFor: (BaseItem) -> String?,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onRetry: () -> Unit,
onShowRequests: () -> Unit,
onLoadMore: () -> Unit,
voiceAvailable: Boolean,
modifier: Modifier = Modifier,
) {
val items = state.results.filter { it.isMovie || it.isSeries }
// Remembered, or the section is re-derived on every recomposition — including the ones
// a focus move causes, which is every press of Down through a long result list.
val requestable = remember(state.results, state.discovery) {
discoverySection(state.results, state.discovery)
}
val sectioned = requestable.isNotEmpty() || state.discoveryLoading
val listState = rememberLazyListState()
val currentOnLoadMore by rememberUpdatedState(onLoadMore)
@@ -92,21 +111,16 @@ internal fun SearchResults(
}
}
val showStartPrompt = state.isDiscovery && items.isEmpty()
val showStartPrompt = state.isDiscovery && items.isEmpty() && requestable.isEmpty()
val nothingFound = items.isEmpty() && requestable.isEmpty() && !state.discoveryLoading
Column(modifier) {
if (!showStartPrompt) {
SearchResultsHeading(
state = state,
resultCount = items.size,
onShowRequests = onShowRequests,
requestFocusRequester = entryFocusRequester,
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
)
SearchResultsHeading(state = state, resultCount = items.size)
Spacer(Modifier.height(8.dp))
}
when {
state.isLoading && items.isEmpty() -> Column(
state.isLoading && items.isEmpty() && requestable.isEmpty() -> Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
@@ -124,37 +138,18 @@ internal fun SearchResults(
showStartPrompt -> SearchStartPrompt(voiceAvailable)
items.isEmpty() -> SearchResultMessage(message = emptyMessage(state))
nothingFound -> SearchResultMessage(message = emptyMessage(state))
else -> LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
if (sectioned && items.isNotEmpty()) {
item(key = "section-available") { SearchSectionHeading("AVAILABLE") }
}
itemsIndexed(items, key = { _, item -> searchResultKey(item) }) { index, item ->
val requestable = item.membyRequestable
val requestCandidate = item.toSearchRequestCandidate()
if (requestable && requestCandidate != null) RequestCard(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.overview.orEmpty().ifBlank { "Request this title" },
status = RequestStatus.REQUESTABLE,
statusLabel = "",
mediaType = requestCandidate.mediaType,
artworkUrl = artworkUrlFor(item),
onFocused = { onItemFocused(item) },
onClick = { onRequest(requestCandidate) },
busy = state.requestingCandidateKey ==
"${requestCandidate.mediaType}:${requestCandidate.foreignId}",
action = RequestActionRequest,
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
.then(if (item.id == returnFocusItemId) Modifier.focusRequester(returnFocusRequester) else Modifier)
.focusProperties {
left = keyboardReturnFocusRequester
if (index == 0 && genresPresent) up = genresFocusRequester
},
) else MediaResultCard(
MediaResultCard(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.genres.firstOrNull().orEmpty().ifBlank { "In your library" },
@@ -182,14 +177,71 @@ internal fun SearchResults(
},
)
}
if (requestable.isNotEmpty()) {
item(key = "section-requestable") { SearchSectionHeading("AVAILABLE TO REQUEST") }
}
// Keys are prefixed rather than shared with the library section. The
// gateway drops what its catalogue already has and discoverySection drops
// the rest, but a keyed list throws on a repeated key — the one failure
// here that is a crash rather than a duplicate poster.
itemsIndexed(requestable, key = { _, item -> "request:" + searchResultKey(item) }) { index, item ->
val candidate = item.toRequestCandidate()
val firstOverall = items.isEmpty() && index == 0
RequestCard(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.overview.orEmpty().ifBlank { "Not in your library yet" },
status = requestCardStatus(item),
statusLabel = "",
mediaType = if (item.isSeries) "series" else "movie",
artworkUrl = artworkUrlFor(item),
onFocused = { onItemFocused(item) },
// The whole card opens the title, exactly as a library result does.
// Requesting from a list is a decision made about a name and a year;
// the page behind it is where somebody can see what they are asking
// for before they ask for it.
onClick = { onItemSelected(item) },
busy = candidate != null && state.requestingCandidateKey ==
"${candidate.mediaType}:${candidate.foreignId}",
// The affordance advertises what is behind the press rather than
// performing it: pressing opens the title's page, and Request is
// the action waiting there.
action = if (item.membyRequestable && candidate != null) RequestActionRequest else null,
modifier = Modifier
.then(if (firstOverall) Modifier.focusRequester(entryFocusRequester) else Modifier)
.then(
if (item.id == returnFocusItemId) {
Modifier.focusRequester(returnFocusRequester)
} else {
Modifier
},
)
.focusProperties {
left = keyboardReturnFocusRequester
if (firstOverall && genresPresent) up = genresFocusRequester
},
)
}
// Never a spinner over the results and never a blocking state: the library
// section above stays fully usable while this is on screen.
if (state.discoveryLoading) {
item(key = "search-discovery-loading") {
SearchFooterNote("Finding more titles…", MembyAccent)
}
}
if (state.discoveryUnavailable) {
item(key = "search-discovery-unavailable") {
SearchFooterNote("Additional results unavailable", MembyMutedText)
}
}
state.requestMessage?.let { message ->
item(key = "search-request-message") {
SearchFooterNote(message, if (state.requestMessageIsError) MembyMutedText else MembyAccent)
}
}
if (state.isLoadingMore) {
item(key = "search-loading-more") {
Text(
"Loading more",
color = MembyMutedText,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
)
SearchFooterNote("Loading more", MembyMutedText)
}
}
state.pagingErrorMessage?.let {
@@ -209,24 +261,52 @@ internal fun SearchResults(
}
}
private fun BaseItem.toSearchRequestCandidate(): GatewayRequestCandidate? {
val mediaType = when (membySource) {
"sonarr" -> "series"
"radarr" -> "movie"
else -> return null
/** A section rule. Never focusable: it is a label, and a remote must pass straight over it. */
@Composable
private fun SearchSectionHeading(label: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
color = MembyMutedText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.2.sp,
)
Spacer(Modifier.width(10.dp))
Box(
Modifier
.weight(1f)
.height(1.dp)
.background(MembyMutedText.copy(alpha = 0.22f)),
)
}
val provider = if (mediaType == "series") "Tvdb" else "Tmdb"
val foreignId = providerIds[provider]?.toIntOrNull() ?: return null
return GatewayRequestCandidate(
mediaType = mediaType,
foreignId = foreignId,
title = name,
year = productionYear ?: 0,
overview = overview.orEmpty(),
posterUrl = membyPosterUrl.orEmpty(),
}
@Composable
private fun SearchFooterNote(message: String, colour: Color) {
Text(
message,
color = colour,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
)
}
/**
* What the card says about a title nothing in the library answers for.
*
* The vocabulary is the gateway's an unknown slug is read as requestable rather than
* dropped, so a state invented on the server tomorrow still draws a card today.
*/
private fun requestCardStatus(item: BaseItem): String = when (item.membySearchState) {
"requested", "processing" -> RequestStatus.PROCESSING
"pending" -> RequestStatus.PENDING
else -> RequestStatus.REQUESTABLE
}
@Composable
private fun SearchStartPrompt(voiceAvailable: Boolean) {
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
@@ -279,9 +359,6 @@ private fun SearchStartPrompt(voiceAvailable: Boolean) {
private fun SearchResultsHeading(
state: SearchUiState,
resultCount: Int,
onShowRequests: () -> Unit,
requestFocusRequester: FocusRequester,
keyboardReturnFocusRequester: FocusRequester,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -305,20 +382,6 @@ private fun SearchResultsHeading(
Spacer(Modifier.width(9.dp))
Text("Searching…", color = MembyAccent, fontSize = 12.sp)
}
if (
state.requestsAvailable && state.genre == null && shouldSearch(state.query) &&
!state.requestMode && state.errorMessage == null
) {
Spacer(Modifier.width(12.dp))
MembyChoiceChip(
label = "Request",
selected = false,
onClick = onShowRequests,
modifier = Modifier
.then(if (resultCount == 0) Modifier.focusRequester(requestFocusRequester) else Modifier)
.focusProperties { left = keyboardReturnFocusRequester },
)
}
}
}
@@ -0,0 +1,110 @@
package com.ponzischeme89.memby.ui.genre
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.TvRailCollapsedWidth
import com.ponzischeme89.memby.ui.theme.MembySurface
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the Services strip above the Genres rail to PNGs under
* `build/screenshots/services-rail/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ServicesRailScreenshotTest"
* ```
*
* The thing worth judging here is the same one [GenreRailScreenshotTest] exists for: no
* unit test can say whether the gap between the two strips reads as "related but
* separate" at three metres, or whether four circular shortcuts sit comfortably above a
* vertical list of text rows without overpowering it.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ServicesRailScreenshotTest {
@get:Rule
val compose = createComposeRule()
/** Nothing chosen on either strip — the page's default state. */
@Test
fun `no service or genre selected`() {
capture("services-rail-default", activeId = ALL_SERVICES_CATEGORY_ID, selectedServiceId = null)
}
/** The remote on a service icon, with that service's shelf on screen. */
@Test
fun `remote on netflix`() {
capture(
"services-rail-netflix-focused",
activeId = "service-netflix",
selectedServiceId = "service-netflix",
)
}
/**
* The remote has moved down into the genre rail; Netflix stays selected underneath it
* and must still say so without wearing the focus ring the [GenreRailItem] and
* [ServiceIconButton] promise that focus and selection are marked separately.
*/
@Test
fun `service selection survives focus moving to genres`() {
capture(
"services-rail-selection-persists",
activeId = "drama",
selectedServiceId = "service-netflix",
genreActiveId = "drama",
)
}
private fun capture(
name: String,
activeId: String,
selectedServiceId: String?,
genreActiveId: String = ALL_MEDIA_CATEGORY_ID,
) {
val services = serviceCategoryTabs()
val categories = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE)
compose.setContent {
Row(Modifier.fillMaxSize().background(MembySurface)) {
Box(Modifier.width(TvRailCollapsedWidth).fillMaxSize().background(MembySurface))
GenreRail(
categories = categories,
activeCategoryId = genreActiveId,
navigationFocusRequester = FocusRequester(),
activeFocusRequester = FocusRequester(),
onCategoryFocused = {},
onEnterContent = { false },
focusForCapture = genreActiveId.takeIf { activeId == genreActiveId && activeId != ALL_SERVICES_CATEGORY_ID && services.none { it.id == activeId } },
topContent = {
ServicesRail(
services = services,
activeServiceId = activeId,
selectedServiceId = selectedServiceId,
navigationFocusRequester = FocusRequester(),
activeFocusRequester = FocusRequester(),
onServiceFocused = {},
focusForCapture = activeId.takeIf { services.any { s -> s.id == activeId } },
)
},
)
Box(Modifier.fillMaxSize().background(MembySurface))
}
}
compose.onRoot().captureRoboImage("build/screenshots/services-rail/$name.png")
}
}
@@ -1,35 +0,0 @@
package com.ponzischeme89.memby.ui.search
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RecommendationRequestScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `recommendation request panel`() {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
.asImageBitmap()
compose.setContent { RecommendationRequestPreview(previewArtwork = artwork) }
compose.onNodeWithText("Request something new").fetchSemanticsNode()
compose.onRoot().captureRoboImage(
"build/screenshots/recommendation-request/recommendation-request-panel.png",
)
}
}
@@ -0,0 +1,111 @@
package com.ponzischeme89.memby.ui.search
import com.ponzischeme89.memby.data.MIN_DISCOVERY_QUERY_LENGTH
import com.ponzischeme89.memby.data.discoveryStillRelevant
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.normaliseDiscoveryQuery
import com.ponzischeme89.memby.data.shouldDiscover
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rules that decide when somebody else's service gets asked anything, and what happens
* to the answer. They are pure so that "Sonarr is not queried on every keystroke" can be an
* assertion rather than something read off a log.
*/
class SearchDiscoveryTest {
@Test
fun `typing a title produces one external query, not one per letter`() {
// Every prefix on the way to a word, as the on-screen keyboard produces them.
val typed = listOf(
"D", "Di", "Dis", "Disc", "Discl", "Disclo", "Disclos", "Disclosu",
"Disclosur", "Disclosure", "Disclosure ", "Disclosure D", "Disclosure Da",
"Disclosure Day",
)
// The debounce collapses the run to whatever was typed when it stopped; what this
// pins is the half that is not a timer — the floor and the normalisation, which is
// what stops a pause mid-word and a trailing space being their own lookups.
val eligible = typed.filter(::shouldDiscover).map(::normaliseDiscoveryQuery).distinct()
assertFalse("a one- or two-letter fragment must never reach an *arr", eligible.contains("d"))
assertFalse(eligible.contains("di"))
assertEquals(
"a trailing space is not a new question",
1,
eligible.count { it == "disclosure" },
)
assertTrue(eligible.contains("disclosure day"))
}
@Test
fun `the same question in different clothes is one question`() {
val expected = normaliseDiscoveryQuery("disclosure day")
for (query in listOf("Disclosure Day", " disclosure day ", "DISCLOSURE DAY")) {
assertEquals(query, expected, normaliseDiscoveryQuery(query))
}
// And a genuinely different one is not.
assertTrue(normaliseDiscoveryQuery("disclosure") != expected)
}
@Test
fun `the floor is counted in characters of the normalised query`() {
assertEquals(3, MIN_DISCOVERY_QUERY_LENGTH)
assertFalse(shouldDiscover(" d "))
assertFalse(shouldDiscover("di"))
assertTrue(shouldDiscover(" dis "))
}
@Test
fun `a section survives the word being extended and not anything else`() {
// The newer answer is already on its way; taking the section away between two
// keystrokes is worse than briefly showing one a letter behind.
assertTrue(discoveryStillRelevant("disclosure", "Disclosure Day"))
assertTrue(discoveryStillRelevant("disclosure", "disclosure"))
// A different question, and one being backspaced away, are not.
assertFalse(discoveryStillRelevant("disclosure", "severance"))
assertFalse(discoveryStillRelevant("disclosure", "disclos"))
assertFalse(discoveryStillRelevant("disclosure", ""))
}
@Test
fun `a title the library answered for is never offered as requestable as well`() {
val library = listOf(
item(id = "emby-1", name = "Disclosure", type = "Movie", tmdb = "551"),
item(id = "emby-2", name = "Severance", type = "Series"),
)
val external = listOf(
// The same film by its stable id, which is how a catalogue that has not caught
// up would otherwise produce two cards.
item(id = "radarr:551", name = "Disclosure (1994)", type = "Movie", tmdb = "551"),
// The same series with no id in common: the title fallback is what catches it.
item(id = "sonarr:9", name = "Severance", type = "Series", tvdb = "9"),
item(id = "radarr:77", name = "Disclosure Day", type = "Movie", tmdb = "77"),
)
val offered = discoverySection(library, external)
assertEquals(listOf("Disclosure Day"), offered.map { it.name })
}
@Test
fun `nothing in the library means everything external is still offered`() {
val external = listOf(item(id = "radarr:77", name = "Disclosure Day", type = "Movie", tmdb = "77"))
assertEquals(external, discoverySection(emptyList(), external))
}
private fun item(
id: String,
name: String,
type: String,
tmdb: String? = null,
tvdb: String? = null,
) = BaseItem(
id = id,
name = name,
type = type,
providerIds = buildMap {
tmdb?.let { put("Tmdb", it) }
tvdb?.let { put("Tvdb", it) }
},
)
}
@@ -0,0 +1,164 @@
package com.ponzischeme89.memby.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.RequestDetailsOverlay
import com.ponzischeme89.memby.ui.search.components.SearchResults
import com.ponzischeme89.memby.ui.theme.MembySurface
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The claim this feature makes is a *visual* one that a second section arriving under the
* first reads as more being found rather than as the results having been replaced and no
* unit test can check it. These are the four states worth looking at: the library alone,
* the library with requestable titles under it, the moment in between while the *arrs are
* still answering, and the case the library cannot answer at all.
*
* `build/screenshots/search-sections/`
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SearchSectionsScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `the library alone carries no section headings`() {
capture("library-only", state(results = library))
}
@Test
fun `requestable titles arrive under their own heading`() {
capture("with-requestable", state(results = library, discovery = external))
}
@Test
fun `the library stays usable while the arrs are still answering`() {
capture(
"discovery-in-flight",
state(results = library, discoveryLoading = true, discoveryQuery = "disclosure"),
)
}
@Test
fun `an arr that cannot be asked says so quietly and never as an error`() {
capture(
"discovery-unavailable",
state(results = library, discoveryUnavailable = true),
)
}
@Test
fun `nothing in the library still finds something to request`() {
capture("requestable-only", state(results = emptyList(), discovery = external))
}
@Test
fun `a requestable title opens a page before it is asked for`() {
compose.setContent {
Box(Modifier.fillMaxSize().background(MembySurface)) {
RequestDetailsOverlay(
card = external.first(),
posterUrl = null,
onRequest = {},
onClose = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/search-sections/request-page.png")
}
private fun capture(name: String, state: SearchUiState) {
compose.setContent {
Box(Modifier.fillMaxSize().background(MembySurface).padding(28.dp)) {
SearchResults(
state = state,
entryFocusRequester = FocusRequester(),
genresFocusRequester = FocusRequester(),
genresPresent = false,
keyboardReturnFocusRequester = FocusRequester(),
returnFocusItemId = null,
returnFocusRequester = FocusRequester(),
artworkUrlFor = { null },
onItemFocused = {},
onItemSelected = {},
onRetry = {},
onLoadMore = {},
voiceAvailable = true,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/search-sections/$name.png")
}
private fun state(
results: List<BaseItem>,
discovery: List<BaseItem> = emptyList(),
discoveryLoading: Boolean = false,
discoveryUnavailable: Boolean = false,
discoveryQuery: String = "",
) = SearchUiState(
query = "disclosure",
results = results,
discovery = discovery,
discoveryQuery = discoveryQuery.ifEmpty { if (discovery.isEmpty()) "" else "disclosure" },
discoveryLoading = discoveryLoading,
discoveryUnavailable = discoveryUnavailable,
hasSearched = true,
requestsAvailable = true,
)
private val library = listOf(
BaseItem(
id = "emby-1", name = "Disclosure", type = "Movie", productionYear = 1994,
genres = listOf("Thriller"), providerIds = mapOf("Tmdb" to "9314"),
membySearchState = "available",
),
BaseItem(
id = "emby-2", name = "The Disclosure Tapes", type = "Series", productionYear = 2021,
genres = listOf("Documentary"), providerIds = mapOf("Tvdb" to "4411"),
membySearchState = "available",
),
)
private val external = listOf(
BaseItem(
id = "radarr:77", name = "Disclosure Day", type = "Movie", productionYear = 2027,
overview = "A leaked memo gives one archivist forty-eight hours to decide what the " +
"world is allowed to know.",
providerIds = mapOf("Tmdb" to "77"), membySource = "radarr",
membySearchState = "requestable", membyRequestable = true,
),
BaseItem(
id = "sonarr:88", name = "Disclosure: The Hearings", type = "Series",
productionYear = 2026,
overview = "Six weeks of testimony, reconstructed from the transcripts.",
providerIds = mapOf("Tvdb" to "88"), membySource = "sonarr",
membySearchState = "processing",
),
)
}
+6
View File
@@ -286,11 +286,17 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/search/stream", s.authed(s.handleSearchStream))
// External discovery is its own route so a keystroke never waits on Sonarr or
// Radarr. See search_discover.go.
v1.Handle("GET /v1/search/discover", s.authed(s.handleSearchDiscover))
// A genre is browsed, not searched: the chip is a filter and this is the route that
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
v1.Handle("GET /v1/genres/affinity", s.authed(s.handleGenreAffinity))
// A streaming service shortcut on the Genres page is the same shelf, filtered on
// Studios rather than Genres. See handleServiceItems in genres.go.
v1.Handle("GET /v1/services/{service}/items", s.authed(s.handleServiceItems))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
+41 -14
View File
@@ -50,18 +50,45 @@ func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess s
writeError(w, http.StatusBadRequest, "a genre is required")
return
}
s.handleBrowseItems(w, r, sess, genre)
s.handleBrowseItems(w, r, sess, "Genres", "genre", genre)
}
func (s *Server) handleLibraryItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.handleBrowseItems(w, r, sess, "")
s.handleBrowseItems(w, r, sess, "Genres", "genre", "")
}
// handleServiceItems answers the Genres page's Services shortcuts — Apple TV+, Netflix,
// Disney+ and the rest of the studio/network catalogue in ui/genre/GenreCategories.kt.
//
// It is the same shelf as a genre, filtered on a different Emby field: services are
// filtered on Studios, which is where a metadata agent (TMDb chief among them) records a
// title's production or distribution company — "Netflix", "Apple TV+", "Disney+" are
// ordinary studio names to Emby, so no new metadata or import step is needed. Like
// genres, the catalogue mapping a service to its Emby spellings is a client-side product
// list rather than server data, for the same reason genreCategories() is: the order is a
// design decision that must never jump around while home rows are arriving, and a service
// with no matching titles simply returns an empty page rather than needing to be hidden
// from a central registry.
func (s *Server) handleServiceItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
service := strings.TrimSpace(r.PathValue("service"))
if service == "" {
writeError(w, http.StatusBadRequest, "a service is required")
return
}
s.handleBrowseItems(w, r, sess, "Studios", "service", service)
}
func (s *Server) handleBrowseItems(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
genre string,
// filterField is the Emby query parameter the filter value is written into — "Genres"
// for a genre shelf, "Studios" for a service shelf. filterLabel is only for the cache
// key and the log line, so the two shelves' entries can never collide or be confused
// for one another in a shared keyspace.
filterField string,
filterLabel string,
filterValue string,
) {
ctx := r.Context()
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
@@ -73,8 +100,8 @@ func (s *Server) handleBrowseItems(
}
filterKey := "all"
if genre != "" {
filterKey = "genre:" + genre
if filterValue != "" {
filterKey = filterLabel + ":" + filterValue
}
key := cache.UserKey(viewerKeyOf(ctx, sess), "browse:"+itemType+":"+filterKey+":"+itoa(offset)+":"+itoa(limit))
if raw, err := s.cache.Get(ctx, key); err == nil {
@@ -95,8 +122,8 @@ func (s *Server) handleBrowseItems(
"SortBy": {"PremiereDate,SortName"},
"SortOrder": {"Descending"},
}, fieldsRow)
if genre != "" {
params.Set("Genres", genre)
if filterValue != "" {
params.Set(filterField, filterValue)
}
// rowParams turns this off for the home rows, which never page. Here it is the number
// the scroll stops on.
@@ -115,20 +142,20 @@ func (s *Server) handleBrowseItems(
total := genreTotal(result.TotalRecordCount, offset, len(items), limit)
// The first page is somebody opening a genre, which is a navigation event worth the
// log; the pages after it are one viewer scrolling and would bury it.
if offset == 0 && genre != "" {
s.loggerFor(ctx).Info("genre browsed", "genre", genre, "results", len(items), "total", total)
// The first page is somebody opening a genre or a service, which is a navigation event
// worth the log; the pages after it are one viewer scrolling and would bury it.
if offset == 0 && filterValue != "" {
s.loggerFor(ctx).Info(filterLabel+" browsed", filterLabel, filterValue, "results", len(items), "total", total)
} else if offset == 0 {
s.loggerFor(ctx).Info("library browsed", "type", itemType, "results", len(items), "total", total)
} else if genre != "" {
s.loggerFor(ctx).Debug("genre page", "genre", genre, "offset", offset, "results", len(items))
} else if filterValue != "" {
s.loggerFor(ctx).Debug(filterLabel+" page", filterLabel, filterValue, "offset", offset, "results", len(items))
} else {
s.loggerFor(ctx).Debug("library page", "type", itemType, "offset", offset, "results", len(items))
}
body, err := json.Marshal(genrePage{
Genre: genre,
Genre: filterValue,
Items: items,
Offset: offset,
Limit: limit,
+7 -2
View File
@@ -714,7 +714,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
return
}
limit := queryInt(r, "limit", 40, 100)
key := cache.UserKey(viewerKeyOf(ctx, sess), "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
// v2: the row now carries ProviderIds, so an entry written by the previous build
// cannot hide a field the deduplication against external discovery depends on.
key := cache.UserKey(viewerKeyOf(ctx, sess), "search:v2:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
// Every search the tab performs is recorded here, before the cache is consulted, so a
// query answered from Redis counts the same as one that reached Emby. The client also
@@ -736,7 +738,10 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
"IncludeItemTypes": {"Movie,Series,Episode"},
"Recursive": {"true"},
"Limit": {itoa(limit)},
}, fieldsRow))
// ProviderIds is what lets the television collapse a library result and an *arr
// discovery result for one title into a single card, on a stable id rather than
// on a title string.
}, fieldsRow+",ProviderIds"))
if err != nil {
s.writeUpstreamError(ctx, w, err, "search failed")
return
+281
View File
@@ -0,0 +1,281 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
// External discovery is a separate route from search on purpose.
//
// /v1/search answers out of Emby and Redis and is what a keystroke asks. This asks Sonarr
// and Radarr, which are somebody else's services on somebody else's network — a lookup
// measured in seconds, rate-limited upstream, and worth nothing at all for the letters on
// the way to a word. Splitting them is the whole of the promise that discovery can never
// delay the library: the television makes the two requests independently and renders
// whichever answers, so an *arr that is down or slow costs the second section and never
// the first.
const (
// Three characters, not the search floor of two. Two letters match a large part of any
// catalogue and the answer is noise; the *arrs are also the one backend here that Memby
// does not own, so the floor is set by what is worth asking them rather than by what
// they would tolerate.
minDiscoveryQueryRunes = 3
// How long one query's answer is reused for the whole household. A film's existence in
// TMDb does not change minute to minute, and this is what makes leaving Search and
// coming back, deleting and retyping a title, or a second television asking the same
// thing free. It is deliberately well past the client's own debounce: the debounce
// protects against typing, this protects against everything else.
discoveryCacheTTL = 10 * time.Minute
// A lookup that found nothing is kept for less. Nothing is the answer for a typo, which
// is the query most likely to be corrected and asked again a second later — but it is
// also the answer while an *arr is confused, and that recovers.
discoveryEmptyTTL = 2 * time.Minute
// Bounds the whole route. Longer than a library search, because that is the trade this
// route exists to isolate, and short enough that a television is never left with a
// section claiming it is still finding things for a minute.
discoveryTimeout = 8 * time.Second
)
// discoveryResponse is deliberately not just an item list: a television has to be able to
// tell "there is nothing else to be had" from "we could not ask", because the first is an
// answer and the second is worth a quiet line under the results.
type discoveryResponse struct {
Query string `json:"query"`
Items []json.RawMessage `json:"items"`
// Partial means at least one source failed. The items that did arrive are still good.
Partial bool `json:"partial,omitempty"`
// Skipped names why nothing was asked at all, for the log and for the client's own
// diagnostics. An empty list with no reason means the *arrs simply knew nothing.
Skipped string `json:"skipped,omitempty"`
}
// normaliseDiscoveryQuery is what makes the cache and the client's own deduplication agree
// on when two searches are the same search. "Disclosure", "disclosure" and "disclosure "
// are one question; "disclosure day" is another.
func normaliseDiscoveryQuery(term string) string {
return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(term))), " ")
}
// discoveryQueryEligible is the one rule, counted in runes rather than bytes for the reason
// searchQueryRecordable is: a title in Japanese is rejected at a third of an English one's
// length otherwise.
func discoveryQueryEligible(term string) bool {
return len([]rune(normaliseDiscoveryQuery(term))) >= minDiscoveryQueryRunes
}
func (s *Server) handleSearchDiscover(w http.ResponseWriter, r *http.Request, sess store.Session) {
started := time.Now()
term := normaliseDiscoveryQuery(r.URL.Query().Get("q"))
limit := queryInt(r, "limit", 20, 60)
log := s.loggerFor(r.Context())
if !discoveryQueryEligible(term) {
s.writeDiscovery(w, discoveryResponse{Query: term, Skipped: "query_too_short"})
return
}
sonarrOn := s.sonarrEnabled(r.Context())
radarrOn := s.radarrEnabled(r.Context())
if !sonarrOn && !radarrOn {
s.writeDiscovery(w, discoveryResponse{Query: term, Skipped: "no_sources"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
canRequest := s.requestAllowed(r, sess)
type leg struct {
source string
items []json.RawMessage
cached bool
err error
took time.Duration
}
results := make(chan leg, 2)
var wg sync.WaitGroup
// Sonarr and Radarr are independent, and one being unreachable must never cost the
// other's answer — the same reason the two halves of a request lookup run apart.
start := func(source string, load func(context.Context) ([]json.RawMessage, error)) {
wg.Add(1)
go func() {
defer wg.Done()
at := time.Now()
items, cached, err := s.discoverCached(ctx, source, term, limit, load)
results <- leg{source: source, items: items, cached: cached, err: err, took: time.Since(at)}
}()
}
if sonarrOn {
start("sonarr", func(ctx context.Context) ([]json.RawMessage, error) {
return s.discoverSonarr(ctx, term, limit)
})
}
if radarrOn {
start("radarr", func(ctx context.Context) ([]json.RawMessage, error) {
return s.discoverRadarr(ctx, term, limit)
})
}
go func() { wg.Wait(); close(results) }()
merged := make([]json.RawMessage, 0, limit)
response := discoveryResponse{Query: term}
fields := []any{"query", term, "limit", limit}
found := 0
for result := range results {
if result.err != nil {
response.Partial = true
log.Debug("discovery source failed", "query", term, "source", result.source, "error", result.err)
}
found += len(result.items)
for _, item := range result.items {
merged = mergeSearchRaw(merged, item, limit)
}
fields = append(fields,
result.source, len(result.items),
result.source+"_ms", result.took.Milliseconds(),
result.source+"_cache", cacheWord(result.cached))
}
response.Items = applyRequestable(merged, canRequest)
// DEBUG for the reason the search line is: this is the record of what somebody was
// looking for, and it is a per-viewer stream of titles rather than something the
// ordinary log should carry. An operator tuning the debounce turns it on.
log.Debug("search discovery", append(fields,
"results", len(response.Items),
"duplicates", found-len(response.Items),
"partial", response.Partial,
"ms", time.Since(started).Milliseconds())...)
s.writeDiscovery(w, response)
}
func (s *Server) writeDiscovery(w http.ResponseWriter, response discoveryResponse) {
if response.Items == nil {
response.Items = []json.RawMessage{}
}
writeJSON(w, http.StatusOK, response)
}
func cacheWord(hit bool) string {
if hit {
return "hit"
}
return "miss"
}
// discoverCached is where the cooldown lives.
//
// The key is a MetadataKey rather than a UserKey, and that is load-bearing: what Radarr
// knows about "disclosure day" is a fact about the world and the household's catalogue,
// with nothing in it derived from whoever typed it — so one lookup answers for every
// television in the house. Whether *this* viewer may press Request is applied afterwards,
// on the way out, which is the same separation cache.MetadataKey demands everywhere else.
//
// cachedRead brings the other half for free: two televisions missing the same key in the
// same second make one lookup between them rather than two.
func (s *Server) discoverCached(
ctx context.Context,
source, term string,
limit int,
load func(context.Context) ([]json.RawMessage, error),
) ([]json.RawMessage, bool, error) {
key := cache.MetadataKey("discover:v1:" + source + ":" + itoa(limit) + ":" + term)
body, hit, err := s.cachedRead(ctx, key, discoveryCacheTTL, func(ctx context.Context) (json.RawMessage, error) {
items, err := load(ctx)
if err != nil {
return nil, err
}
return json.Marshal(nonNil(items))
})
if err != nil {
return nil, hit, err
}
var items []json.RawMessage
if err := json.Unmarshal(body, &items); err != nil {
return nil, hit, err
}
return items, hit, nil
}
// discoverSonarr and discoverRadarr differ from the streaming search's own legs in one
// way: a title the household already has is dropped rather than listed. The library
// section above it is already showing that title, playable, and one result appearing twice
// under two headings is the single worst thing progressive search can do.
func (s *Server) discoverSonarr(ctx context.Context, term string, limit int) ([]json.RawMessage, error) {
series, err := s.sonarr.Lookup(ctx, term)
if err != nil {
return nil, err
}
ids := make([]int, 0, len(series))
for _, v := range series {
if v.TVDBID > 0 {
ids = append(ids, v.TVDBID)
}
}
inLibrary, _ := s.store.LibraryContainsProviderIDs(ctx, "Tvdb", ids)
items := make([]json.RawMessage, 0, limit)
for _, v := range series {
if v.TVDBID == 0 || inLibrary[v.TVDBID] || len(items) >= limit {
continue
}
state := lookupStatusFor(RequestSubject{
Tracked: v.ID > 0,
Released: seriesReleased(v.Status, v.NextAiring, time.Now()),
}, false)
items = append(items, searchExternalItem(v.Title, v.Year, v.Overview, "Series", "sonarr",
state, strconv.Itoa(v.TVDBID), sonarrCoverURL(v.Images, "poster"), false))
}
return items, nil
}
func (s *Server) discoverRadarr(ctx context.Context, term string, limit int) ([]json.RawMessage, error) {
movies, err := s.radarr.Lookup(ctx, term)
if err != nil {
return nil, err
}
ids := make([]int, 0, len(movies))
for _, v := range movies {
if v.TMDBID > 0 {
ids = append(ids, v.TMDBID)
}
}
inLibrary, _ := s.store.LibraryContainsProviderIDs(ctx, "Tmdb", ids)
items := make([]json.RawMessage, 0, limit)
for _, v := range movies {
if v.TMDBID == 0 || inLibrary[v.TMDBID] || len(items) >= limit {
continue
}
state := lookupStatusFor(RequestSubject{
Tracked: v.ID > 0,
HasFile: v.HasFile,
Released: movieReleased(v.Status),
}, false)
items = append(items, searchExternalItem(v.Title, v.Year, v.Overview, "Movie", "radarr",
state, strconv.Itoa(v.TMDBID), radarrCoverURL(v.Images, "poster"), false))
}
return items, nil
}
// applyRequestable is what keeps the cached half free of the viewer. Whether the button is
// offered is this person's permission and nothing else's, so it is stamped on the way out
// rather than baked into what the household shares.
func applyRequestable(items []json.RawMessage, canRequest bool) []json.RawMessage {
for i, raw := range items {
var item map[string]any
if json.Unmarshal(raw, &item) != nil {
continue
}
state, _ := item["MembySearchState"].(string)
item["MembyRequestable"] = canRequest && state == RequestStatusRequestable
if encoded, err := json.Marshal(item); err == nil {
items[i] = encoded
}
}
return items
}
@@ -0,0 +1,67 @@
package api
import (
"encoding/json"
"testing"
)
func TestDiscoveryQueryNormalisationCollapsesTheSameQuestion(t *testing.T) {
same := []string{"Disclosure Day", "disclosure day", " disclosure day ", "DISCLOSURE Day"}
want := normaliseDiscoveryQuery(same[0])
for _, query := range same[1:] {
if got := normaliseDiscoveryQuery(query); got != want {
t.Fatalf("%q normalised to %q, want %q", query, got, want)
}
}
if normaliseDiscoveryQuery("disclosure") == want {
t.Fatal("a materially different query must not share a cache key")
}
}
func TestDiscoveryFloorIsCountedInRunes(t *testing.T) {
for _, query := range []string{"", " ", "d", "di", " di "} {
if discoveryQueryEligible(query) {
t.Fatalf("%q should not reach Sonarr or Radarr", query)
}
}
for _, query := range []string{"dis", "disclosure day", "君の名は"} {
if !discoveryQueryEligible(query) {
t.Fatalf("%q should be eligible for discovery", query)
}
}
}
func TestRequestableIsStampedOnTheWayOut(t *testing.T) {
items := []json.RawMessage{
searchExternalItem("Disclosure Day", 2027, "", "Movie", "radarr", RequestStatusRequestable, "12", "", false),
searchExternalItem("Something Tracked", 2020, "", "Series", "sonarr", RequestStatusProcessing, "34", "", false),
}
for _, canRequest := range []bool{true, false} {
decorated := applyRequestable(append([]json.RawMessage{}, items...), canRequest)
var requestable, processing map[string]any
if err := json.Unmarshal(decorated[0], &requestable); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(decorated[1], &processing); err != nil {
t.Fatal(err)
}
if requestable["MembyRequestable"] != canRequest {
t.Fatalf("canRequest=%v: requestable candidate got %v", canRequest, requestable["MembyRequestable"])
}
// Only a title nothing has and nobody has asked for offers the button, whatever
// this viewer is permitted to do.
if processing["MembyRequestable"] != false {
t.Fatalf("canRequest=%v: a tracked title must never offer Request", canRequest)
}
}
}
func TestDiscoveryMergeCollapsesOnProviderID(t *testing.T) {
merged := []json.RawMessage{}
one := searchExternalItem("Disclosure Day", 2027, "", "Movie", "radarr", RequestStatusRequestable, "12", "", false)
merged = mergeSearchRaw(merged, one, 20)
merged = mergeSearchRaw(merged, one, 20)
if len(merged) != 1 {
t.Fatalf("one title reported twice produced %d cards", len(merged))
}
}