0.3.23 - Search radarr/sonarr
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
|||||||
rel="icon"
|
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"
|
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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -805,13 +805,13 @@ export function AccountPage() {
|
|||||||
renaming={namingViewer.id !== null}
|
renaming={namingViewer.id !== null}
|
||||||
busy={busy === 'viewer-name'}
|
busy={busy === 'viewer-name'}
|
||||||
onCancel={() => setNamingViewer(null)}
|
onCancel={() => setNamingViewer(null)}
|
||||||
onConfirm={(name) =>
|
onConfirm={(name, pin) =>
|
||||||
void act(
|
void act(
|
||||||
'viewer-name',
|
'viewer-name',
|
||||||
() =>
|
() =>
|
||||||
namingViewer.id
|
namingViewer.id
|
||||||
? api.put(`${base}/viewers/${encodeURIComponent(namingViewer.id)}`, { name })
|
? 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.',
|
namingViewer.id ? 'Viewer renamed.' : 'Viewer added.',
|
||||||
() => {
|
() => {
|
||||||
setNamingViewer(null);
|
setNamingViewer(null);
|
||||||
@@ -1117,10 +1117,11 @@ function ViewerNameDialog({
|
|||||||
initial: string;
|
initial: string;
|
||||||
renaming: boolean;
|
renaming: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onConfirm: (name: string) => void;
|
onConfirm: (name: string, pin?: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial);
|
const [name, setName] = useState(initial);
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
return (
|
return (
|
||||||
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
|
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
|
||||||
<div className="dialog" role="dialog" aria-modal="true">
|
<div className="dialog" role="dialog" aria-modal="true">
|
||||||
@@ -1138,11 +1139,28 @@ function ViewerNameDialog({
|
|||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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">
|
<div className="dialog-actions">
|
||||||
<Button variant="quiet" onClick={onCancel}>
|
<Button variant="quiet" onClick={onCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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'}
|
{renaming ? 'Rename' : 'Add viewer'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
|||||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||||
|
|
||||||
val defaultVersionName = "0.3.22"
|
val defaultVersionName = "0.3.23"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -964,6 +964,8 @@ class EmbyRepository internal constructor(
|
|||||||
return searchRepository.search(term, limit)
|
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.
|
* 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. */
|
/** Primary (poster) image URL, used as a card fallback. */
|
||||||
fun primaryUrl(item: BaseItem, maxWidth: Int = ARTWORK_CARD_MAX_WIDTH): String? {
|
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
|
val tag = item.imageTags["Primary"] ?: return null
|
||||||
return imageUrl(item.id, "Primary", tag, maxWidth)
|
return imageUrl(item.id, "Primary", tag, maxWidth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.data
|
|||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.GenreAffinity
|
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.EmbyApi
|
||||||
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
||||||
import com.ponzischeme89.memby.data.remote.GatewayApi
|
import com.ponzischeme89.memby.data.remote.GatewayApi
|
||||||
@@ -14,6 +15,9 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
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.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
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. */
|
/** One stable page filtered by genre; episodes are excluded deliberately. */
|
||||||
suspend fun browseGenre(
|
suspend fun browseGenre(
|
||||||
genre: String,
|
genre: String,
|
||||||
|
|||||||
@@ -458,6 +458,10 @@ data class BaseItem(
|
|||||||
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
|
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
|
||||||
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
|
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
|
||||||
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
|
@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
|
// 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.
|
// show Sonarr follows but Emby has never imported, so the card stays informational.
|
||||||
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
|
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import retrofit2.http.POST
|
|||||||
import retrofit2.http.PUT
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
import retrofit2.http.Streaming
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Memby gateway API.
|
* The Memby gateway API.
|
||||||
@@ -116,6 +118,10 @@ interface GatewayApi {
|
|||||||
@GET("v1/search")
|
@GET("v1/search")
|
||||||
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
|
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,
|
* 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.
|
* 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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -17,10 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Button
|
import androidx.tv.material3.Button
|
||||||
@@ -28,7 +25,6 @@ import androidx.tv.material3.Text
|
|||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
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.MembyOnSurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
@@ -37,54 +33,42 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
|
internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
|
||||||
var pin by remember { mutableStateOf("") }
|
var pin by remember { mutableStateOf("") }
|
||||||
var confirmation by remember { mutableStateOf("") }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
|
Modifier.fillMaxSize().background(MembySurface).padding(48.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("Create your Memby PIN", color = MembyOnSurface, fontSize = 32.sp)
|
Text("Create your Memby PIN", color = MembyOnSurface, fontSize = 28.sp)
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
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.",
|
"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))
|
Spacer(Modifier.height(24.dp))
|
||||||
PinField(pin, { pin = it }, "PIN")
|
PinBoxesField(pin, { pin = it; error = null })
|
||||||
Spacer(Modifier.height(10.dp))
|
error?.let {
|
||||||
PinField(confirmation, { confirmation = it }, "Confirm PIN")
|
Spacer(Modifier.height(10.dp))
|
||||||
error?.let { Text(it, color = MembyAccent) }
|
Text(it, color = MembyAccent, fontSize = 13.sp)
|
||||||
Spacer(Modifier.height(18.dp))
|
}
|
||||||
Button(enabled = !saving && pin.length in 4..12 && pin == confirmation, onClick = {
|
Spacer(Modifier.height(20.dp))
|
||||||
error = when {
|
Button(
|
||||||
pin != confirmation -> "The PINs do not match."
|
enabled = !saving && pin.length == PIN_LENGTH,
|
||||||
else -> null
|
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp),
|
||||||
}
|
onClick = {
|
||||||
if (error != null) return@Button
|
saving = true
|
||||||
saving = true
|
scope.launch {
|
||||||
scope.launch {
|
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
|
||||||
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
|
.onSuccess { onComplete() }
|
||||||
.onSuccess { onComplete() }
|
.onFailure { error = "That PIN could not be saved." }
|
||||||
.onFailure { error = "That PIN could not be saved." }
|
saving = false
|
||||||
saving = false
|
}
|
||||||
}
|
},
|
||||||
}) { Text("Save PIN") }
|
) { 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.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -18,16 +17,14 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Button
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.OutlinedButton
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
|
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
|
||||||
@@ -50,39 +47,52 @@ internal fun DeviceRecoveryScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val chosen = selected
|
val chosen = selected
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
|
Modifier.fillMaxSize().background(MembySurface).padding(horizontal = 56.dp, vertical = 40.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("Welcome back", color = MembyOnSurface, fontSize = 32.sp, fontWeight = FontWeight.SemiBold)
|
Text("Welcome back", color = MembyOnSurface, fontSize = 28.sp, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text("This TV is already registered with Memby.", color = MembyQuietText, fontSize = 16.sp)
|
Text("This TV is already registered with Memby.", color = MembyQuietText, fontSize = 14.sp)
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
profiles.distinctBy { it.viewer.id }.forEach { profile ->
|
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) {
|
Column(
|
||||||
Text(profile.viewer.initials, color = MembyAccent, fontSize = 36.sp, fontWeight = FontWeight.Bold)
|
Modifier.width(136.dp).background(MembyControlSurface, RoundedCornerShape(14.dp)).clickable { selected = profile; pin = ""; error = null }.padding(14.dp),
|
||||||
Text(profile.viewer.name, color = MembyOnSurface, fontSize = 15.sp)
|
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 ->
|
chosen?.let { profile ->
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(18.dp))
|
||||||
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText)
|
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText, fontSize = 14.sp)
|
||||||
if (profile.viewer.hasPin) {
|
if (profile.viewer.hasPin) {
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(8.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))
|
PinBoxesField(pin, { pin = it; error = null })
|
||||||
}
|
}
|
||||||
error?.let { Text(it, color = MembyAccent) }
|
error?.let {
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Button(onClick = {
|
Text(it, color = MembyAccent, fontSize = 13.sp)
|
||||||
if (profile.viewer.hasPin && pin.isBlank()) { error = "Enter your PIN"; return@Button }
|
}
|
||||||
scope.launch {
|
Spacer(Modifier.height(12.dp))
|
||||||
runCatching { ServiceLocator.repository.recoverWithPIN(profile, pin) }.onSuccess { onRecovered() }.onFailure { error = "That PIN was not accepted." }
|
Button(
|
||||||
}
|
enabled = !profile.viewer.hasPin || pin.length == PIN_LENGTH,
|
||||||
}) { Text("Continue") }
|
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))
|
Spacer(Modifier.height(14.dp))
|
||||||
Button(onClick = onFullSignIn) { Text("Use full Emby sign-in") }
|
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)
|
onItemFocused(item)
|
||||||
},
|
},
|
||||||
onItemSelected = onItemSelected,
|
onItemSelected = onItemSelected,
|
||||||
|
onRequest = viewModel::request,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onShowRequests = viewModel::showRequests,
|
onShowRequests = viewModel::showRequests,
|
||||||
onLoadMore = viewModel::loadMore,
|
onLoadMore = viewModel::loadMore,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
data class SearchUiState(
|
data class SearchUiState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
@@ -269,6 +270,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
it.genre == genreAtRequest
|
it.genre == genreAtRequest
|
||||||
it.copy(
|
it.copy(
|
||||||
requestingCandidateKey = null,
|
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 ->
|
requestCandidates = if (stillShowingRequest) it.requestCandidates.map { option ->
|
||||||
if (option.mediaType == candidate.mediaType &&
|
if (option.mediaType == candidate.mediaType &&
|
||||||
option.foreignId == candidate.foreignId
|
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
|
// Previous results stay on screen underneath the spinner: a flash of empty pane
|
||||||
// between two letters reads as breakage, not as progress.
|
// between two letters reads as breakage, not as progress.
|
||||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
runCatching { repository.search(term) }
|
runCatching {
|
||||||
.onSuccess { found ->
|
repository.searchProgress(term).collect { found ->
|
||||||
// collectLatest cancels the preceding request, but cancellation is
|
// collectLatest cancels the preceding request, but cancellation is
|
||||||
// cooperative: an HTTP call that has already returned can still reach
|
// cooperative: an HTTP call that has already returned can still reach
|
||||||
// this non-suspending block. Drop it unless the field still asks the
|
// this non-suspending block. Drop it unless the field still asks the
|
||||||
// exact question that produced the answer.
|
// exact question that produced the answer.
|
||||||
if (_state.value.genre != null || _state.value.query.trim() != term) {
|
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
|
// 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
|
// 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
|
// 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.
|
// 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
|
// Keep the backend's relevance and personalisation order within each
|
||||||
// textual tier, but never let those softer signals bury the title the
|
// textual tier, but never let those softer signals bury the title the
|
||||||
// viewer typed exactly. This matters especially in the one-column list,
|
// viewer typed exactly. This matters especially in the one-column list,
|
||||||
// where tenth place is ten rows away rather than the second grid row.
|
// where tenth place is ten rows away rather than the second grid row.
|
||||||
val ranked = rankSearchResults(term, items)
|
val ranked = if (_state.value.results.isEmpty()) rankSearchResults(term, items) else items
|
||||||
cache[term] = ranked
|
_state.update { it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null) }
|
||||||
viewModelScope.launch { repository.recordSearch(term) }
|
|
||||||
_state.update {
|
|
||||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
|
||||||
}
|
|
||||||
if (_state.value.requestMode) loadRequestCandidates(term)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
// A cancelled search is the normal case while typing, not a failure.
|
// A cancelled search is the normal case while typing, not a failure.
|
||||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
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))
|
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) {
|
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 {
|
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
|
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.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.MembyChoiceChip
|
||||||
import com.ponzischeme89.memby.ui.components.media.MediaResultCard
|
import com.ponzischeme89.memby.ui.components.media.MediaResultCard
|
||||||
import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton
|
import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton
|
||||||
import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph
|
import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph
|
||||||
import com.ponzischeme89.memby.ui.search.SearchUiState
|
import com.ponzischeme89.memby.ui.search.SearchUiState
|
||||||
import com.ponzischeme89.memby.ui.search.shouldSearch
|
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.FactSeparator
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||||
@@ -67,6 +70,7 @@ internal fun SearchResults(
|
|||||||
artworkUrlFor: (BaseItem) -> String?,
|
artworkUrlFor: (BaseItem) -> String?,
|
||||||
onItemFocused: (BaseItem) -> Unit,
|
onItemFocused: (BaseItem) -> Unit,
|
||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
|
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onShowRequests: () -> Unit,
|
onShowRequests: () -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
@@ -126,8 +130,30 @@ internal fun SearchResults(
|
|||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
|
itemsIndexed(items, key = { _, item -> searchResultKey(item) }) { index, item ->
|
||||||
MediaResultCard(
|
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,
|
title = item.name,
|
||||||
subtitle = searchResultSubtitle(item),
|
subtitle = searchResultSubtitle(item),
|
||||||
detail = item.genres.firstOrNull().orEmpty().ifBlank { "In your library" },
|
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
|
@Composable
|
||||||
private fun SearchStartPrompt(voiceAvailable: Boolean) {
|
private fun SearchStartPrompt(voiceAvailable: Boolean) {
|
||||||
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
|
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
|
||||||
@@ -319,6 +363,16 @@ private fun searchResultSubtitle(item: BaseItem): String = listOfNotNull(
|
|||||||
item.isMovie -> "Film"
|
item.isMovie -> "Film"
|
||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
|
when (item.membySearchState) {
|
||||||
|
"available" -> "Available"
|
||||||
|
"requested", "processing", "pending" -> "Requested / monitored"
|
||||||
|
"requestable" -> "Request"
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
).joinToString(FactSeparator)
|
).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
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -18,16 +14,14 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
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.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.MembyOnSurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
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) {
|
Column(Modifier.fillMaxSize().background(MembySurface), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||||
Text("Set a PIN for ${viewer.name}", color = MembyOnSurface, fontSize = 28.sp)
|
Text("Set a PIN for ${viewer.name}", color = MembyOnSurface, fontSize = 28.sp)
|
||||||
Spacer(Modifier.height(8.dp))
|
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))
|
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) }
|
failure?.let { Text(it, color = MembyAccent) }
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
ViewerActionButton(label = "Cancel", icon = MembyIcon.Close, onClick = onCancel)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,6 +285,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
v1.Handle("GET /v1/heroes/active", s.authed(s.handleActiveHero))
|
v1.Handle("GET /v1/heroes/active", s.authed(s.handleActiveHero))
|
||||||
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||||||
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
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
|
// 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.
|
// 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/genres/{genre}/items", s.authed(s.handleGenreItems))
|
||||||
|
|||||||
@@ -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, ¤tItem)
|
||||||
|
// 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 @@
|
|||||||
0.1.69
|
0.1.74
|
||||||
|
|||||||
Reference in New Issue
Block a user