0.3.23 - Search radarr/sonarr

This commit is contained in:
ponzischeme89
2026-08-25 15:39:43 +12:00
parent 0fcc02f57e
commit 772635784c
21 changed files with 603 additions and 117 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-BNwLRlCd.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BRiE93Cp.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CtoC2zbP.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-BLZnmdjh.css">
</head>
<body>
<div id="root"></div>
+22 -4
View File
@@ -805,13 +805,13 @@ export function AccountPage() {
renaming={namingViewer.id !== null}
busy={busy === 'viewer-name'}
onCancel={() => setNamingViewer(null)}
onConfirm={(name) =>
onConfirm={(name, pin) =>
void act(
'viewer-name',
() =>
namingViewer.id
? api.put(`${base}/viewers/${encodeURIComponent(namingViewer.id)}`, { name })
: api.post(`${base}/viewers`, { name }),
: api.post(`${base}/viewers`, { name, ...(pin ? { pin } : {}) }),
namingViewer.id ? 'Viewer renamed.' : 'Viewer added.',
() => {
setNamingViewer(null);
@@ -1117,10 +1117,11 @@ function ViewerNameDialog({
initial: string;
renaming: boolean;
busy: boolean;
onConfirm: (name: string) => void;
onConfirm: (name: string, pin?: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial);
const [pin, setPin] = useState('');
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
@@ -1138,11 +1139,28 @@ function ViewerNameDialog({
onChange={(event) => setName(event.target.value)}
/>
</Field>
{!renaming ? (
<Field label="Initial PIN" hint="Optional. Use 4 to 12 numbers, or leave it blank to add one later.">
<input
type="password"
inputMode="numeric"
minLength={4}
maxLength={12}
value={pin}
onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 12))}
/>
</Field>
) : null}
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" busy={busy} disabled={!name.trim()} onClick={() => onConfirm(name.trim())}>
<Button
variant="primary"
busy={busy}
disabled={!name.trim() || (pin.length > 0 && pin.length < 4)}
onClick={() => onConfirm(name.trim(), pin || undefined)}
>
{renaming ? 'Rename' : 'Add viewer'}
</Button>
</div>
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.22"
val defaultVersionName = "0.3.23"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -964,6 +964,8 @@ class EmbyRepository internal constructor(
return searchRepository.search(term, limit)
}
fun searchProgress(term: String, limit: Int = 40) = searchRepository.searchProgress(term, limit)
/**
* One page of a genre, dual-path like the search beside it.
*
@@ -3334,6 +3336,7 @@ class EmbyRepository internal constructor(
/** Primary (poster) image URL, used as a card fallback. */
fun primaryUrl(item: BaseItem, maxWidth: Int = ARTWORK_CARD_MAX_WIDTH): String? {
item.membyPosterUrl?.takeIf(String::isNotBlank)?.let { return it }
val tag = item.imageTags["Primary"] ?: return null
return imageUrl(item.id, "Primary", tag, maxWidth)
}
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.data
import android.os.SystemClock
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GenreAffinity
import com.ponzischeme89.memby.data.model.GatewayItems
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
@@ -14,6 +15,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -65,6 +69,22 @@ class SearchRepository internal constructor(
)
}
/** Emits cumulative search snapshots as Emby, Sonarr and Radarr finish independently. */
fun searchProgress(term: String, limit: Int = 40): Flow<List<BaseItem>> = flow {
if (!ServerConfig.isGateway) { emit(search(term, limit)); return@flow }
requireGateway().searchStream(term.trim(), limit).use { body ->
val reader = body.charStream().buffered()
while (true) {
val line = reader.readLine() ?: break
if (line.isBlank()) continue
val snapshot = runCatching {
Json { ignoreUnknownKeys = true }.decodeFromString<GatewayItems>(line)
}.getOrNull()
snapshot?.let { emit(it.items) }
}
}
}
/** One stable page filtered by genre; episodes are excluded deliberately. */
suspend fun browseGenre(
genre: String,
@@ -458,6 +458,10 @@ data class BaseItem(
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
@SerialName("MembySearchState") val membySearchState: String? = null,
@SerialName("MembyRequestable") val membyRequestable: Boolean = false,
@SerialName("MembyPosterURL") val membyPosterUrl: String? = null,
@SerialName("ProviderIds") val providerIds: Map<String, String> = emptyMap(),
// The Emby series a schedule card stands for, when the library holds it. Absent for a
// show Sonarr follows but Emby has never imported, so the card stays informational.
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
@@ -40,6 +40,8 @@ import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import okhttp3.ResponseBody
/**
* The Memby gateway API.
@@ -116,6 +118,10 @@ 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
/**
* One page of a genre. A filter, not a query: the genre is the path rather than a term,
* so the gateway can ask Emby the question actually being asked.
@@ -0,0 +1,84 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
/**
* How many digits a Memby PIN holds.
*
* Every entry surface — setup, device recovery, and setting a PIN for somebody else from
* viewer management — renders exactly this many boxes and asks for exactly this many
* digits. A PIN nobody can see needs its length stated visually rather than discovered by
* typing past the end, and a fixed length is what makes a single confirmation entry safe to
* drop: mistyping one digit of four is corrected by simply retyping it, not by comparing
* two blind fields against each other.
*/
internal const val PIN_LENGTH = 4
/**
* A row of [PIN_LENGTH] boxes driven by one hidden [BasicTextField].
*
* A television has one remote and no reliable way to move a D-pad between four separate
* text fields, so this is deliberately one focus target — the boxes are decoration drawn
* from the field's own value, never fields of their own. That is also what lets the
* system's numeric keyboard drive it "beautifully": a remote's number keys, a phone-remote
* app, or an on-screen keypad all just type into the one field the platform already knows
* how to route digits into. `performTextInput`/`onNode(hasSetTextAction())` in the
* screenshot tests keeps working unchanged, because there is still exactly one settable
* node underneath the boxes.
*/
@Composable
internal fun PinBoxesField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BasicTextField(
value = value,
onValueChange = { onValueChange(it.filter(Char::isDigit).take(PIN_LENGTH)) },
textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp),
cursorBrush = SolidColor(Color.Transparent),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = modifier,
decorationBox = {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
repeat(PIN_LENGTH) { index ->
val filled = index < value.length
val active = index == value.length
Box(
Modifier
.size(46.dp)
.background(MembyControlSurface, RoundedCornerShape(8.dp))
.border(
if (active) 2.dp else 0.dp,
if (active) MembyAccent else Color.Transparent,
RoundedCornerShape(8.dp),
),
contentAlignment = Alignment.Center,
) {
if (filled) Text("", color = MembyAccent, fontSize = 22.sp)
}
}
}
},
)
}
@@ -3,12 +3,12 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -17,10 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
@@ -28,7 +25,6 @@ import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
@@ -37,54 +33,42 @@ import kotlinx.coroutines.launch
@Composable
internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
var pin by remember { mutableStateOf("") }
var confirmation by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
Modifier.fillMaxSize().background(MembySurface).padding(48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Create your Memby PIN", color = MembyOnSurface, fontSize = 32.sp)
Spacer(Modifier.height(14.dp))
Text("Create your Memby PIN", color = MembyOnSurface, fontSize = 28.sp)
Spacer(Modifier.height(10.dp))
Text(
"This PIN belongs to your Memby profile and can be used to sign back in on this device, even after you log out or reinstall Memby.",
color = MembyQuietText, fontSize = 18.sp,
color = MembyQuietText,
fontSize = 15.sp,
textAlign = TextAlign.Center,
modifier = Modifier.width(460.dp),
)
Spacer(Modifier.height(24.dp))
PinField(pin, { pin = it }, "PIN")
Spacer(Modifier.height(10.dp))
PinField(confirmation, { confirmation = it }, "Confirm PIN")
error?.let { Text(it, color = MembyAccent) }
Spacer(Modifier.height(18.dp))
Button(enabled = !saving && pin.length in 4..12 && pin == confirmation, onClick = {
error = when {
pin != confirmation -> "The PINs do not match."
else -> null
}
if (error != null) return@Button
saving = true
scope.launch {
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
.onSuccess { onComplete() }
.onFailure { error = "That PIN could not be saved." }
saving = false
}
}) { Text("Save PIN") }
PinBoxesField(pin, { pin = it; error = null })
error?.let {
Spacer(Modifier.height(10.dp))
Text(it, color = MembyAccent, fontSize = 13.sp)
}
Spacer(Modifier.height(20.dp))
Button(
enabled = !saving && pin.length == PIN_LENGTH,
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp),
onClick = {
saving = true
scope.launch {
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
.onSuccess { onComplete() }
.onFailure { error = "That PIN could not be saved." }
saving = false
}
},
) { Text("Save PIN") }
}
}
@Composable
private fun PinField(value: String, onValueChange: (String) -> Unit, label: String) {
BasicTextField(
value = value,
onValueChange = { onValueChange(it.filter(Char::isDigit).take(12)) },
textStyle = TextStyle(color = MembyOnSurface, fontSize = 22.sp),
cursorBrush = SolidColor(MembyAccent),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(260.dp).background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp),
decorationBox = { field -> if (value.isEmpty()) Text(label, color = MembyQuietText); field() },
)
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -18,16 +17,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.OutlinedButton
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
@@ -50,39 +47,52 @@ internal fun DeviceRecoveryScreen(
val scope = rememberCoroutineScope()
val chosen = selected
Column(
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
Modifier.fillMaxSize().background(MembySurface).padding(horizontal = 56.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Welcome back", color = MembyOnSurface, fontSize = 32.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
Text("This TV is already registered with Memby.", color = MembyQuietText, fontSize = 16.sp)
Spacer(Modifier.height(28.dp))
Text("Welcome back", color = MembyOnSurface, fontSize = 28.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Text("This TV is already registered with Memby.", color = MembyQuietText, fontSize = 14.sp)
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
profiles.distinctBy { it.viewer.id }.forEach { profile ->
Column(Modifier.width(150.dp).background(MembyControlSurface, RoundedCornerShape(14.dp)).clickable { selected = profile; pin = ""; error = null }.padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(profile.viewer.initials, color = MembyAccent, fontSize = 36.sp, fontWeight = FontWeight.Bold)
Text(profile.viewer.name, color = MembyOnSurface, fontSize = 15.sp)
Column(
Modifier.width(136.dp).background(MembyControlSurface, RoundedCornerShape(14.dp)).clickable { selected = profile; pin = ""; error = null }.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(profile.viewer.initials, color = MembyAccent, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(profile.viewer.name, color = MembyOnSurface, fontSize = 14.sp)
}
}
}
chosen?.let { profile ->
Spacer(Modifier.height(24.dp))
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText)
Spacer(Modifier.height(18.dp))
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText, fontSize = 14.sp)
if (profile.viewer.hasPin) {
Spacer(Modifier.height(10.dp))
BasicTextField(pin, { pin = it.filter(Char::isDigit).take(12) }, textStyle = androidx.compose.ui.text.TextStyle(color = MembyOnSurface, fontSize = 22.sp), cursorBrush = SolidColor(MembyAccent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp))
Spacer(Modifier.height(8.dp))
PinBoxesField(pin, { pin = it; error = null })
}
error?.let { Text(it, color = MembyAccent) }
Spacer(Modifier.height(14.dp))
Button(onClick = {
if (profile.viewer.hasPin && pin.isBlank()) { error = "Enter your PIN"; return@Button }
scope.launch {
runCatching { ServiceLocator.repository.recoverWithPIN(profile, pin) }.onSuccess { onRecovered() }.onFailure { error = "That PIN was not accepted." }
}
}) { Text("Continue") }
error?.let {
Spacer(Modifier.height(6.dp))
Text(it, color = MembyAccent, fontSize = 13.sp)
}
Spacer(Modifier.height(12.dp))
Button(
enabled = !profile.viewer.hasPin || pin.length == PIN_LENGTH,
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp),
onClick = {
scope.launch {
runCatching { ServiceLocator.repository.recoverWithPIN(profile, pin) }.onSuccess { onRecovered() }.onFailure { error = "That PIN was not accepted." }
}
},
) { Text("Continue") }
}
Spacer(Modifier.height(22.dp))
Button(onClick = onFullSignIn) { Text("Use full Emby sign-in") }
Spacer(Modifier.height(14.dp))
OutlinedButton(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 6.dp),
onClick = onFullSignIn,
) { Text("Use full Emby sign-in", fontSize = 13.sp) }
}
}
@@ -329,6 +329,7 @@ fun SearchScreen(
onItemFocused(item)
},
onItemSelected = onItemSelected,
onRequest = viewModel::request,
onRetry = viewModel::retry,
onShowRequests = viewModel::showRequests,
onLoadMore = viewModel::loadMore,
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
data class SearchUiState(
val query: String = "",
@@ -269,6 +270,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.genre == genreAtRequest
it.copy(
requestingCandidateKey = null,
results = if (stillShowingRequest) it.results.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
@@ -424,32 +430,28 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// 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.search(term) }
.onSuccess { found ->
runCatching {
repository.searchProgress(term).collect { 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@onSuccess
return@collect
}
// 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 = found.distinctItems()
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 = rankSearchResults(term, items)
cache[term] = ranked
viewModelScope.launch { repository.recordSearch(term) }
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
}
if (_state.value.requestMode) loadRequestCandidates(term)
val ranked = if (_state.value.results.isEmpty()) rankSearchResults(term, items) else items
_state.update { it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null) }
}
}
.onFailure { error ->
// A cancelled search is the normal case while typing, not a failure.
if (error is kotlinx.coroutines.CancellationException) throw error
@@ -460,6 +462,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
}
}
if (_state.value.genre == null && _state.value.query.trim() == term) {
cache[term] = _state.value.results
viewModelScope.launch { repository.recordSearch(term) }
if (_state.value.requestMode) loadRequestCandidates(term)
}
}
private suspend fun loadRequestCandidates(term: String) {
@@ -497,6 +504,26 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/** Merges cumulative server snapshots without moving cards already visible to the viewer. */
private fun mergeProgressiveSearchResults(
current: List<BaseItem>, incoming: List<BaseItem>,
): List<BaseItem> {
val byKey = incoming.associateBy { searchIdentity(it) }
val used = HashSet<String>()
val stable = current.mapNotNull { old -> byKey[searchIdentity(old)]?.also { used += searchIdentity(old) } ?: old }
return stable + incoming.filter { used.add(searchIdentity(it)) }
}
private fun searchIdentity(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { item.providerIds[it]?.takeIf(String::isNotBlank)?.let { id -> "$it:$id" } }
.firstOrNull() ?: "${item.type}:${item.name.trim().lowercase()}"
private fun searchRequestKey(item: BaseItem): String? = when (item.membySource) {
"sonarr" -> item.providerIds["Tvdb"]?.let { "series:$it" }
"radarr" -> item.providerIds["Tmdb"]?.let { "movie:$it" }
else -> null
}
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
@@ -40,12 +40,15 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
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.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.shouldSearch
import com.ponzischeme89.memby.ui.requests.RequestCard
import com.ponzischeme89.memby.ui.requests.RequestActionRequest
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyIcon
@@ -67,6 +70,7 @@ internal fun SearchResults(
artworkUrlFor: (BaseItem) -> String?,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onRetry: () -> Unit,
onShowRequests: () -> Unit,
onLoadMore: () -> Unit,
@@ -126,8 +130,30 @@ internal fun SearchResults(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
MediaResultCard(
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(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.genres.firstOrNull().orEmpty().ifBlank { "In your library" },
@@ -182,6 +208,24 @@ internal fun SearchResults(
}
}
private fun BaseItem.toSearchRequestCandidate(): GatewayRequestCandidate? {
val mediaType = when (membySource) {
"sonarr" -> "series"
"radarr" -> "movie"
else -> return null
}
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 SearchStartPrompt(voiceAvailable: Boolean) {
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
@@ -319,6 +363,16 @@ private fun searchResultSubtitle(item: BaseItem): String = listOfNotNull(
item.isMovie -> "Film"
else -> null
},
when (item.membySearchState) {
"available" -> "Available"
"requested", "processing", "pending" -> "Requested / monitored"
"requestable" -> "Request"
else -> null
},
).joinToString(FactSeparator)
private fun searchResultKey(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { provider -> item.providerIds[provider]?.takeIf(String::isNotBlank)?.let { "$provider:$it" } }
.firstOrNull() ?: item.id
private const val LOAD_MORE_ITEMS_AHEAD = 4
@@ -7,10 +7,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -18,16 +14,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.PIN_LENGTH
import com.ponzischeme89.memby.ui.PinBoxesField
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
@@ -157,14 +151,14 @@ internal fun ViewerPinEntry(
Column(Modifier.fillMaxSize().background(MembySurface), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text("Set a PIN for ${viewer.name}", color = MembyOnSurface, fontSize = 28.sp)
Spacer(Modifier.height(8.dp))
Text("Use 4 to 12 digits to protect this profile.", color = MembyQuietText)
Text("Use a 4-digit PIN to protect this profile.", color = MembyQuietText)
Spacer(Modifier.height(20.dp))
BasicTextField(pin, { pin = it.filter(Char::isDigit).take(12) }, textStyle = TextStyle(color = MembyOnSurface, fontSize = 24.sp), cursorBrush = SolidColor(MembyAccent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.width(220.dp).background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp))
PinBoxesField(pin, { pin = it })
failure?.let { Text(it, color = MembyAccent) }
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ViewerActionButton(label = "Cancel", icon = MembyIcon.Close, onClick = onCancel)
ViewerActionButton(label = "Save PIN", icon = MembyIcon.Check, onClick = { onConfirm(pin) }, enabled = !saving && pin.length in 4..12, emphasised = true)
ViewerActionButton(label = "Save PIN", icon = MembyIcon.Check, onClick = { onConfirm(pin) }, enabled = !saving && pin.length == PIN_LENGTH, emphasised = true)
}
}
}
@@ -0,0 +1,80 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.theme.MembyTheme
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
/**
* Captures the two customer-facing PIN states under `build/screenshots/pin-login/`.
*
* These are deliberately rendered as plain state-driven screens: the repository call is
* outside the visual contract, while the selected profile and masked entry are the parts a
* viewer needs to understand at a glance.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class PinLoginScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `setting a pin`() {
compose.setContent {
MembyTheme {
PinSetupScreen(
viewer = MembyViewer(
id = "main",
name = "Matt",
kind = MembyViewer.KIND_MAIN,
),
onComplete = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/pin-login/pin-setup.png")
}
@Test
fun `logging in with a pin`() {
compose.setContent {
MembyTheme {
DeviceRecoveryScreen(
profiles = listOf(
GatewayRecoveryProfile(
deviceId = "living-room",
viewer = MembyViewer(
id = "v1",
name = "Matt",
kind = MembyViewer.KIND_MAIN,
hasPin = true,
),
),
),
onRecovered = {},
onFullSignIn = {},
)
}
}
compose.onNode(hasClickAction() and hasText("Matt")).performClick()
compose.onNode(hasSetTextAction()).performTextInput("4826")
compose.onRoot().captureRoboImage("build/screenshots/pin-login/pin-login.png")
}
}
+1
View File
@@ -285,6 +285,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/heroes/active", s.authed(s.handleActiveHero))
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))
// 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))
+200
View File
@@ -0,0 +1,200 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Search updates are newline-delimited JSON rather than one large JSON document. The first
// update is deliberately allowed to come from whichever upstream answers first.
func (s *Server) handleSearchStream(w http.ResponseWriter, r *http.Request, sess store.Session) {
term := strings.TrimSpace(r.URL.Query().Get("q"))
if len([]rune(term)) < minSearchQueryRunes {
writeJSON(w, http.StatusOK, map[string]any{"items": []json.RawMessage{}})
return
}
limit := queryInt(r, "limit", 40, 100)
s.recordSearchQuery(r.Context(), sess, term)
canRequest := s.requestAllowed(r, sess)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
flusher, _ := w.(http.Flusher)
enc := json.NewEncoder(w)
updates := make(chan []json.RawMessage, 3)
var wg sync.WaitGroup
start := func(fn func(context.Context) []json.RawMessage) {
wg.Add(1)
go func() { defer wg.Done(); updates <- fn(ctx) }()
}
// Emby, Sonarr and Radarr are independent. In particular, an unavailable *arr must
// never hold back the library answer.
start(func(ctx context.Context) []json.RawMessage {
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
"SearchTerm": {term}, "IncludeItemTypes": {"Movie,Series,Episode"},
"Recursive": {"true"}, "Limit": {itoa(limit)},
}, fieldsRow+",ProviderIds"))
if err != nil {
return nil
}
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
s.decorateItems(ctx, items)
return markSearchAvailable(items)
})
if s.sonarrEnabled(ctx) {
start(func(ctx context.Context) []json.RawMessage { return s.streamSonarr(ctx, term, limit, canRequest) })
}
if s.radarrEnabled(ctx) {
start(func(ctx context.Context) []json.RawMessage { return s.streamRadarr(ctx, term, limit, canRequest) })
}
go func() { wg.Wait(); close(updates) }()
merged := make([]json.RawMessage, 0, limit)
for update := range updates {
for _, item := range update {
merged = mergeSearchRaw(merged, item, limit)
}
if err := enc.Encode(map[string]any{"items": merged}); err != nil {
return
}
if flusher != nil {
flusher.Flush()
}
}
}
func markSearchAvailable(items []json.RawMessage) []json.RawMessage {
for i, raw := range items {
var item map[string]any
if json.Unmarshal(raw, &item) != nil {
continue
}
item["MembySearchState"] = RequestStatusAvailable
if encoded, err := json.Marshal(item); err == nil {
items[i] = encoded
}
}
return items
}
func (s *Server) streamSonarr(ctx context.Context, term string, limit int, canRequest bool) []json.RawMessage {
series, err := s.sonarr.Lookup(ctx, term)
if err != nil {
return nil
}
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 || len(items) >= limit {
continue
}
state := lookupStatusFor(RequestSubject{Tracked: v.ID > 0, InLibrary: inLibrary[v.TVDBID], 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"), canRequest))
}
return items
}
func (s *Server) streamRadarr(ctx context.Context, term string, limit int, canRequest bool) []json.RawMessage {
movies, err := s.radarr.Lookup(ctx, term)
if err != nil {
return nil
}
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 || len(items) >= limit {
continue
}
state := lookupStatusFor(RequestSubject{Tracked: v.ID > 0, HasFile: v.HasFile, InLibrary: inLibrary[v.TMDBID], 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"), canRequest))
}
return items
}
func searchExternalItem(title string, year int, overview, itemType, source, state, providerID, poster string, canRequest bool) json.RawMessage {
item := map[string]any{"Id": source + ":" + providerID, "Name": title, "Type": itemType,
"ProductionYear": year, "Overview": overview, "MembySource": source,
"MembySearchState": state, "MembyPlayable": false, "MembyPosterURL": poster,
"MembyRequestable": canRequest && state == RequestStatusRequestable,
"ProviderIds": map[string]string{map[string]string{"sonarr": "Tvdb", "radarr": "Tmdb"}[source]: providerID}}
raw, _ := json.Marshal(item)
return raw
}
func mergeSearchRaw(existing []json.RawMessage, candidate json.RawMessage, limit int) []json.RawMessage {
key := searchRawKey(candidate)
for i, current := range existing {
if searchRawKey(current) != key {
continue
}
var currentItem map[string]any
_ = json.Unmarshal(current, &currentItem)
// An Emby item is authoritative and stays playable even when an *arr later finds it.
if source, _ := currentItem["MembySource"].(string); source == "" {
var incoming map[string]any
_ = json.Unmarshal(candidate, &incoming)
if incoming["MembySource"] != nil {
return existing
}
return existing
}
var merged map[string]any
_ = json.Unmarshal(current, &merged)
var incoming map[string]any
_ = json.Unmarshal(candidate, &incoming)
if incoming["MembySource"] == nil {
existing[i] = candidate
return existing
}
for k, v := range incoming {
merged[k] = v
}
existing[i], _ = json.Marshal(merged)
return existing
}
if len(existing) < limit {
existing = append(existing, candidate)
}
return existing
}
func searchRawKey(raw json.RawMessage) string {
var item struct {
ProviderIDs map[string]string `json:"ProviderIds"`
Name, Type string
}
if json.Unmarshal(raw, &item) == nil {
for _, key := range []string{"Tmdb", "Tvdb", "Imdb"} {
if id := item.ProviderIDs[key]; id != "" {
return key + ":" + id
}
}
}
return strings.ToLower(item.Type + ":" + strings.TrimSpace(item.Name))
}
+1 -1
View File
@@ -1 +1 @@
0.1.69
0.1.74