diff --git a/CLAUDE.md b/CLAUDE.md index 69d6ca8..08587e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90f70fd..fdae99f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index df27c39..11e8747 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SearchDiscovery.kt b/app/src/main/java/com/ponzischeme89/memby/data/SearchDiscovery.kt new file mode 100644 index 0000000..d8e9af2 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/SearchDiscovery.kt @@ -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 = 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+") diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SearchRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/SearchRepository.kt index 0d67d25..b207b44 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SearchRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SearchRepository.kt @@ -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> = 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(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, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 780923f..276f352 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -632,6 +632,22 @@ data class GatewayItems( val items: List = 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 = emptyList(), + val partial: Boolean = false, + val skipped: String? = null, +) + /** * One page of `GET /v1/genres/{genre}/items` or `GET /v1/library/items`. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index 5c07fdd..a6a8369 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt index f21cfcd..8f295fd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RadarrMovieDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RadarrMovieDetailsOverlay.kt index 7bc4e57..b10aff2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/RadarrMovieDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RadarrMovieDetailsOverlay.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RequestDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RequestDetailsOverlay.kt new file mode 100644 index 0000000..a8c1c74 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RequestDetailsOverlay.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index c728e26..0fdf55d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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() } @@ -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, + 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 = 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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index 00f97cc..a8af236 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -19,6 +19,8 @@ private const val MAX_CACHED_CATEGORIES = 15 data class GenreBrowseUiState( val categories: List = emptyList(), + /** The Services strip above [categories] — see [serviceCategoryTabs]. */ + val services: List = emptyList(), val selectedCategoryId: String? = null, val items: List = 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 = _state.asStateFlow() private var pageJob: Job? = null @@ -81,7 +90,7 @@ class GenreBrowseViewModel( private val warmJobs = mutableMapOf() 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt index 9e51ca9..06cd487 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt @@ -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, 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 = listOf(allServicesCategory) + coreServiceCategories + fun genreCategories(itemType: String): List = if (itemType.equals("Movie", ignoreCase = true)) coreGenreCategories // Reality is a television shelf, so it is offered wherever series can appear. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index fea3e64..8cbd1d5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -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(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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchTrace.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchTrace.kt new file mode 100644 index 0000000..f116e18 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchTrace.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index a25d480..6ced60c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -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 = 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 = 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 = 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(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(16, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry?, + ): Boolean = size > CACHE_ENTRIES + } + private var genreSuggestions: List = 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, incoming: List, -): List { - val byKey = incoming.associateBy { searchIdentity(it) } - val used = HashSet() - 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, + 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, discovery: List): List { + 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 create(modelClass: Class): T = SearchViewModel(repository) as T diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt index 5b18ac3..d51252c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt @@ -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 }, - ) - } } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/ServicesRailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/ServicesRailScreenshotTest.kt new file mode 100644 index 0000000..33f6b82 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/ServicesRailScreenshotTest.kt @@ -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") + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/search/RecommendationRequestScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/search/RecommendationRequestScreenshotTest.kt deleted file mode 100644 index b2ba721..0000000 --- a/app/src/test/java/com/ponzischeme89/memby/ui/search/RecommendationRequestScreenshotTest.kt +++ /dev/null @@ -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", - ) - } -} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchDiscoveryTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchDiscoveryTest.kt new file mode 100644 index 0000000..d2bb914 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchDiscoveryTest.kt @@ -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) } + }, + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchSectionsScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchSectionsScreenshotTest.kt new file mode 100644 index 0000000..43823e9 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchSectionsScreenshotTest.kt @@ -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, + discovery: List = 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", + ), + ) +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 9b34397..4466d12 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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)) diff --git a/server/internal/api/genres.go b/server/internal/api/genres.go index 76ca740..aec00e0 100644 --- a/server/internal/api/genres.go +++ b/server/internal/api/genres.go @@ -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, diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 4aab4c2..27c8eb3 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -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 diff --git a/server/internal/api/search_discover.go b/server/internal/api/search_discover.go new file mode 100644 index 0000000..da20245 --- /dev/null +++ b/server/internal/api/search_discover.go @@ -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 +} diff --git a/server/internal/api/search_discover_test.go b/server/internal/api/search_discover_test.go new file mode 100644 index 0000000..f41675d --- /dev/null +++ b/server/internal/api/search_discover_test.go @@ -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)) + } +}