0.3.01
This commit is contained in:
Binary file not shown.
@@ -211,6 +211,21 @@ export interface FeaturePolicy {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
canRollback: boolean;
|
canRollback: boolean;
|
||||||
features: Feature[] | null;
|
features: Feature[] | null;
|
||||||
|
configuration: ConfigurationValue[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigurationValue {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: 'boolean' | 'integer' | 'enum' | 'json';
|
||||||
|
scopes: string[];
|
||||||
|
default: unknown;
|
||||||
|
options?: string[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
value: unknown;
|
||||||
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminStatus {
|
export interface AdminStatus {
|
||||||
|
|||||||
+3
-3
@@ -219,9 +219,9 @@ export const nav: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: 'features',
|
id: 'features',
|
||||||
path: '/admin/features',
|
path: '/admin/features',
|
||||||
label: 'Features',
|
label: 'Client configuration',
|
||||||
title: 'Features',
|
title: 'Client configuration',
|
||||||
intro: 'Roll out, stop and recover optional behaviour with no app release.',
|
intro: 'Control everything the thin TV client renders, without releasing an APK.',
|
||||||
icon: 'sliders',
|
icon: 'sliders',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function FeaturesPage() {
|
|||||||
|
|
||||||
const policy = status?.features;
|
const policy = status?.features;
|
||||||
const features = policy?.features ?? [];
|
const features = policy?.features ?? [];
|
||||||
|
const configuration = policy?.configuration ?? [];
|
||||||
const clients = status?.clients ?? [];
|
const clients = status?.clients ?? [];
|
||||||
const revision = policy?.revision ?? 0;
|
const revision = policy?.revision ?? 0;
|
||||||
|
|
||||||
@@ -64,12 +65,16 @@ export function FeaturesPage() {
|
|||||||
),
|
),
|
||||||
[key]: enabled,
|
[key]: enabled,
|
||||||
});
|
});
|
||||||
|
const valuesFor = (key: string, value: unknown) => ({
|
||||||
|
...Object.fromEntries(configuration.map((item) => [item.key, item.value])),
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Features"
|
title="Client configuration"
|
||||||
intro="Roll out, stop and recover optional behaviour with no app release."
|
intro="One server-owned control plane for what Memby renders: flags, values, page composition and contextual discovery."
|
||||||
/>
|
/>
|
||||||
<Banner message={error} />
|
<Banner message={error} />
|
||||||
|
|
||||||
@@ -77,6 +82,18 @@ export function FeaturesPage() {
|
|||||||
<Loading />
|
<Loading />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Card
|
||||||
|
title="Thin client control plane"
|
||||||
|
intro="The gateway decides which sections, heroes and discovery rows are delivered. The TV remains a fast renderer of known components and safely ignores anything newer."
|
||||||
|
icon="tv"
|
||||||
|
>
|
||||||
|
<div className="chips">
|
||||||
|
<a className="chip" href="/admin/hero">Hero sources and pinned overrides</a>
|
||||||
|
<a className="chip" href="/admin/recommendations">For You and recommendation pools</a>
|
||||||
|
<a className="chip" href="/admin/engagement">Contextual row engagement signals</a>
|
||||||
|
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
<Card
|
<Card
|
||||||
title="Control plane"
|
title="Control plane"
|
||||||
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
|
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
|
||||||
@@ -121,6 +138,66 @@ export function FeaturesPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Central configuration"
|
||||||
|
intro="Typed values use the same revisioned control plane as feature flags. Global values are safe defaults; user, device and experimental scopes are available to the client contract as features grow."
|
||||||
|
icon="sliders"
|
||||||
|
>
|
||||||
|
<div className="stack">
|
||||||
|
{configuration.map((item) => {
|
||||||
|
const value = item.value;
|
||||||
|
return (
|
||||||
|
<div className="row" key={item.key}>
|
||||||
|
<div className="grow">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="hint">{item.description} · {item.scopes.join(', ')}</div>
|
||||||
|
<Chip>{item.key}</Chip>
|
||||||
|
</div>
|
||||||
|
{item.type === 'boolean' ? (
|
||||||
|
<Toggle
|
||||||
|
label={value ? 'On' : 'Off'}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
disabled={busy === item.key}
|
||||||
|
onChange={(next) => void run(item.key, async () => {
|
||||||
|
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
|
||||||
|
await reload();
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : item.type === 'enum' ? (
|
||||||
|
<select value={String(value)} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
|
||||||
|
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
|
||||||
|
await reload();
|
||||||
|
})}>
|
||||||
|
{(item.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
) : item.type === 'json' ? (
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={JSON.stringify(value, null, 2)}
|
||||||
|
disabled={busy === item.key}
|
||||||
|
aria-label={item.name}
|
||||||
|
onChange={(event) => {
|
||||||
|
try {
|
||||||
|
const next = JSON.parse(event.target.value);
|
||||||
|
void run(item.key, async () => {
|
||||||
|
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
|
||||||
|
await reload();
|
||||||
|
});
|
||||||
|
} catch { /* wait for valid JSON before publishing */ }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
|
||||||
|
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, Number(event.target.value)), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
|
||||||
|
await reload();
|
||||||
|
})} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Grid cols="2">
|
<Grid cols="2">
|
||||||
{features.length === 0 ? (
|
{features.length === 0 ? (
|
||||||
<Card title="Nothing registered" icon="sliders">
|
<Card title="Nothing registered" icon="sliders">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -111,6 +111,10 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
|||||||
// says which build it is.
|
// says which build it is.
|
||||||
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
||||||
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
|
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
|
||||||
|
.header("X-Memby-Config-Schema", "1")
|
||||||
|
.header("X-Memby-Components", setOf("mediaRow", "mediaGrid", "genreBrowser", "hero").joinToString(","))
|
||||||
|
.header("X-Memby-Platform", "android-tv")
|
||||||
|
.header("X-Memby-Device", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||||
.header(
|
.header(
|
||||||
"X-Memby-Capabilities",
|
"X-Memby-Capabilities",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,34 +1,45 @@
|
|||||||
package com.ponzischeme89.memby.data.remoteconfig
|
package com.ponzischeme89.memby.data.remoteconfig
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.ponzischeme89.memby.BuildConfig
|
import com.ponzischeme89.memby.BuildConfig
|
||||||
import com.ponzischeme89.memby.data.ServerConfig
|
import com.ponzischeme89.memby.data.ServerConfig
|
||||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URI
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|
||||||
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
|
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
|
||||||
private const val REMOTE_CONFIG_START_DELAY_MS = 5_000L
|
private const val REMOTE_CONFIG_START_DELAY_MS = 5_000L
|
||||||
|
private const val REMOTE_CONFIG_REFRESH_INTERVAL_MS = 15 * 60 * 1_000L
|
||||||
private const val MAX_REMOTE_CONFIG_BYTES = 32L * 1024L
|
private const val MAX_REMOTE_CONFIG_BYTES = 32L * 1024L
|
||||||
private const val PREFERENCES_NAME = "memby_remote_config"
|
private const val PREFERENCES_NAME = "memby_remote_config"
|
||||||
private const val DOCUMENT_KEY = "last_known_good"
|
private const val DOCUMENT_KEY = "last_known_good"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typed, process-stable presentation configuration.
|
* Typed, server-driven configuration. The bundled or cached value is available synchronously;
|
||||||
|
* later valid revisions are published through [RemoteConfigManager.activeFlow].
|
||||||
*
|
*
|
||||||
* The active value is chosen once, before any Activity is created. A successful refresh
|
* The cached value is chosen before any Activity is created. A successful refresh replaces
|
||||||
* only replaces the durable last-known-good document; it deliberately does not publish a
|
* the durable last-known-good document and publishes the new immutable snapshot atomically.
|
||||||
* Flow or mutable state that could repaint wording under somebody's focus.
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MembyRemoteConfig(
|
data class MembyRemoteConfig(
|
||||||
@@ -39,7 +50,22 @@ data class MembyRemoteConfig(
|
|||||||
val copy: RemoteCopy,
|
val copy: RemoteCopy,
|
||||||
val features: RemoteFeatures,
|
val features: RemoteFeatures,
|
||||||
val presentation: RemotePresentation,
|
val presentation: RemotePresentation,
|
||||||
|
val home: RemoteHomeConfig = RemoteHomeConfig(),
|
||||||
|
val movies: RemotePageConfig = RemotePageConfig(),
|
||||||
|
val tv: RemotePageConfig = RemotePageConfig(),
|
||||||
|
val continueWatching: RemoteContinueWatching = RemoteContinueWatching(),
|
||||||
|
val forYou: RemoteForYouConfig = RemoteForYouConfig(),
|
||||||
|
val recommendations: RemoteRecommendations = RemoteRecommendations(),
|
||||||
|
val search: RemoteSearchConfig = RemoteSearchConfig(),
|
||||||
|
val ui: RemoteUiConfig = RemoteUiConfig(),
|
||||||
|
val experimental: Map<String, Boolean> = emptyMap(),
|
||||||
|
val integrations: RemoteIntegrations = RemoteIntegrations(),
|
||||||
|
val branding: RemoteBranding = RemoteBranding(),
|
||||||
) {
|
) {
|
||||||
|
/** Unknown flags deliberately fall back to the caller's compiled-safe behaviour. */
|
||||||
|
fun featureEnabled(key: String, bundledDefault: Boolean = false): Boolean =
|
||||||
|
features.flags[key] ?: experimental[key] ?: bundledDefault
|
||||||
|
|
||||||
val navigation: NavigationRemoteConfig
|
val navigation: NavigationRemoteConfig
|
||||||
get() = NavigationRemoteConfig(
|
get() = NavigationRemoteConfig(
|
||||||
labels = copy.navigation,
|
labels = copy.navigation,
|
||||||
@@ -75,13 +101,94 @@ data class NavigationLabels(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RemoteFeatures(
|
data class RemoteFeatures(
|
||||||
val showNavigationVersion: Boolean,
|
val showNavigationVersion: Boolean = true,
|
||||||
|
val flags: Map<String, Boolean> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RemotePresentation(
|
data class RemotePresentation(
|
||||||
val navigationRailExpandedWidthDp: Int,
|
val navigationRailExpandedWidthDp: Int = 184,
|
||||||
val navigationContentShiftDp: Int,
|
val navigationContentShiftDp: Int = 112,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteHomeConfig(
|
||||||
|
val sections: List<String> = listOf("continue", "for-you", "favorites", "latest-movies"),
|
||||||
|
val sectionDefinitions: List<RemoteSectionDefinition> = emptyList(),
|
||||||
|
val showForYou: Boolean = true,
|
||||||
|
val showSeasonal: Boolean = true,
|
||||||
|
val heroRefreshSeconds: Int = 60,
|
||||||
|
val maxItemsPerRow: Int = 20,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemotePageConfig(
|
||||||
|
val sections: List<String> = listOf("genres", "library"),
|
||||||
|
val sectionDefinitions: List<RemoteSectionDefinition> = emptyList(),
|
||||||
|
val showGenres: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteSectionDefinition(
|
||||||
|
val id: String = "",
|
||||||
|
val type: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val position: Int = 0,
|
||||||
|
val dataSource: String = "",
|
||||||
|
val component: String = "",
|
||||||
|
val maxItems: Int = 0,
|
||||||
|
val destination: String = "",
|
||||||
|
val settings: Map<String, JsonElement> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteContinueWatching(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val includeNextUp: Boolean = true,
|
||||||
|
val maxItems: Int = 20,
|
||||||
|
val progressColour: String = "emby",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteForYouConfig(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val maxRows: Int = 3,
|
||||||
|
val refreshHours: Int = 24,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteRecommendations(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val sections: List<String> = listOf("for-you", "because-you-watched"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteSearchConfig(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val genresEnabled: Boolean = false,
|
||||||
|
val maxResults: Int = 50,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteUiConfig(
|
||||||
|
val artworkStyle: String = "automatic",
|
||||||
|
val cardDensity: String = "standard",
|
||||||
|
val showWatchedBadges: Boolean = true,
|
||||||
|
val showMediaTypeIcons: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteIntegrations(
|
||||||
|
val tracearr: Boolean = false,
|
||||||
|
val sonarr: Boolean = false,
|
||||||
|
val radarr: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteBranding(
|
||||||
|
val markUrl: String = "",
|
||||||
|
val markVersion: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The single UI-facing projection, so Compose never performs stringly typed lookups. */
|
/** The single UI-facing projection, so Compose never performs stringly typed lookups. */
|
||||||
@@ -119,6 +226,17 @@ object BundledRemoteConfig {
|
|||||||
navigationRailExpandedWidthDp = 184,
|
navigationRailExpandedWidthDp = 184,
|
||||||
navigationContentShiftDp = 112,
|
navigationContentShiftDp = 112,
|
||||||
),
|
),
|
||||||
|
home = RemoteHomeConfig(),
|
||||||
|
movies = RemotePageConfig(),
|
||||||
|
tv = RemotePageConfig(),
|
||||||
|
continueWatching = RemoteContinueWatching(),
|
||||||
|
forYou = RemoteForYouConfig(),
|
||||||
|
recommendations = RemoteRecommendations(),
|
||||||
|
search = RemoteSearchConfig(),
|
||||||
|
ui = RemoteUiConfig(),
|
||||||
|
experimental = emptyMap(),
|
||||||
|
integrations = RemoteIntegrations(),
|
||||||
|
branding = RemoteBranding(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +255,12 @@ private val remoteConfigJson = Json {
|
|||||||
* Owns synchronous activation and asynchronous revalidation.
|
* Owns synchronous activation and asynchronous revalidation.
|
||||||
*
|
*
|
||||||
* SharedPreferences gives this one small value an atomic file replacement. The network
|
* SharedPreferences gives this one small value an atomic file replacement. The network
|
||||||
* path commits only after decoding and validation, on an IO dispatcher, and never mutates
|
* path commits only after decoding and validation, on an IO dispatcher, and publishes the
|
||||||
* [active]. A killed write therefore leaves either the old complete value or the new one.
|
* new immutable snapshot only after the durable write succeeds. A killed write therefore
|
||||||
|
* leaves either the old complete value or the new one.
|
||||||
*/
|
*/
|
||||||
class RemoteConfigManager(context: Context) {
|
class RemoteConfigManager(context: Context) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
private val preferences = context.applicationContext.getSharedPreferences(
|
private val preferences = context.applicationContext.getSharedPreferences(
|
||||||
PREFERENCES_NAME,
|
PREFERENCES_NAME,
|
||||||
Context.MODE_PRIVATE,
|
Context.MODE_PRIVATE,
|
||||||
@@ -149,7 +269,12 @@ class RemoteConfigManager(context: Context) {
|
|||||||
?.let(::decodeCachedRemoteConfig)
|
?.let(::decodeCachedRemoteConfig)
|
||||||
?.takeIf { validateRemoteConfig(it.document, BuildConfig.VERSION_NAME) == null }
|
?.takeIf { validateRemoteConfig(it.document, BuildConfig.VERSION_NAME) == null }
|
||||||
|
|
||||||
val active: MembyRemoteConfig = cachedAtStart?.document ?: BundledRemoteConfig.value
|
private val _active = MutableStateFlow(cachedAtStart?.document ?: BundledRemoteConfig.value)
|
||||||
|
val activeFlow: StateFlow<MembyRemoteConfig> = _active.asStateFlow()
|
||||||
|
val active: MembyRemoteConfig get() = _active.value
|
||||||
|
private val markFile = File(appContext.filesDir, "memby_mark.remote")
|
||||||
|
private val _markPath = MutableStateFlow(markFile.takeIf { it.isFile && it.length() > 0L }?.absolutePath)
|
||||||
|
val markPathFlow: StateFlow<String?> = _markPath.asStateFlow()
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@@ -158,18 +283,26 @@ class RemoteConfigManager(context: Context) {
|
|||||||
val gatewayUrl = ServerConfig.gatewayUrl ?: return
|
val gatewayUrl = ServerConfig.gatewayUrl ?: return
|
||||||
val cached = cachedAtStart
|
val cached = cachedAtStart
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
refreshBranding(_active.value.branding)
|
||||||
delay(REMOTE_CONFIG_START_DELAY_MS)
|
delay(REMOTE_CONFIG_START_DELAY_MS)
|
||||||
fetch(gatewayUrl, cached)
|
var etag = cached?.etag
|
||||||
|
while (isActive) {
|
||||||
|
etag = fetch(gatewayUrl, etag)
|
||||||
|
delay(REMOTE_CONFIG_REFRESH_INTERVAL_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetch(gatewayUrl: String, cached: CachedRemoteConfig?) {
|
private fun fetch(gatewayUrl: String, cachedEtag: String?): String? {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(gatewayUrl.trimEnd('/') + "/v1/config")
|
.url(gatewayUrl.trimEnd('/') + "/v1/config")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
||||||
.header("X-Memby-Config-Schema", REMOTE_CONFIG_SCHEMA_VERSION.toString())
|
.header("X-Memby-Config-Schema", REMOTE_CONFIG_SCHEMA_VERSION.toString())
|
||||||
.apply { cached?.etag?.takeIf(String::isNotBlank)?.let { header("If-None-Match", it) } }
|
.header("X-Memby-Components", SUPPORTED_REMOTE_COMPONENTS.joinToString(","))
|
||||||
|
.header("X-Memby-Platform", "android-tv")
|
||||||
|
.header("X-Memby-Device", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
|
.apply { cachedEtag?.takeIf(String::isNotBlank)?.let { header("If-None-Match", it) } }
|
||||||
.build()
|
.build()
|
||||||
val client = HttpStack.base.newBuilder()
|
val client = HttpStack.base.newBuilder()
|
||||||
// This request is speculative and affects only a later process. Give it less
|
// This request is speculative and affects only a later process. Give it less
|
||||||
@@ -181,34 +314,73 @@ class RemoteConfigManager(context: Context) {
|
|||||||
.build()
|
.build()
|
||||||
runCatching {
|
runCatching {
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (response.code == 304) return
|
if (response.code == 304) return cachedEtag
|
||||||
if (!response.isSuccessful) return
|
if (!response.isSuccessful) return cachedEtag
|
||||||
val body = response.body ?: return
|
val body = response.body ?: return cachedEtag
|
||||||
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return
|
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return cachedEtag
|
||||||
val source = body.source()
|
val source = body.source()
|
||||||
source.request(MAX_REMOTE_CONFIG_BYTES + 1)
|
source.request(MAX_REMOTE_CONFIG_BYTES + 1)
|
||||||
if (source.buffer.size > MAX_REMOTE_CONFIG_BYTES) return
|
if (source.buffer.size > MAX_REMOTE_CONFIG_BYTES) return cachedEtag
|
||||||
val raw = source.readUtf8()
|
val raw = source.readUtf8()
|
||||||
val document = decodeRemoteConfig(raw) ?: return
|
val document = decodeRemoteConfig(raw) ?: return cachedEtag
|
||||||
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return
|
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return cachedEtag
|
||||||
val heldVersion = cached?.document?.configVersion ?: 0
|
// Config versions are monotonic hints, not the change detector: a gateway
|
||||||
if (document.configVersion <= heldVersion) return
|
// may republish the same schema/config revision after changing a policy.
|
||||||
|
// The ETag is authoritative, and a 200 with a new ETag is always eligible.
|
||||||
val etag = response.header("ETag")?.takeIf {
|
val etag = response.header("ETag")?.takeIf {
|
||||||
it.isNotBlank() && it.length <= 128 && !it.contains('\n') && !it.contains('\r')
|
it.isNotBlank() && it.length <= 128 && !it.contains('\n') && !it.contains('\r')
|
||||||
} ?: return
|
} ?: return cachedEtag
|
||||||
val encoded = remoteConfigJson.encodeToString(CachedRemoteConfig(etag, document))
|
val encoded = remoteConfigJson.encodeToString(CachedRemoteConfig(etag, document))
|
||||||
if (!preferences.edit().putString(DOCUMENT_KEY, encoded).commit()) {
|
if (!preferences.edit().putString(DOCUMENT_KEY, encoded).commit()) {
|
||||||
Log.w("MembyRemoteConfig", "Could not persist remote configuration")
|
Log.w("MembyRemoteConfig", "Could not persist remote configuration")
|
||||||
}
|
}
|
||||||
|
if (_active.value != document) _active.value = document
|
||||||
|
refreshBranding(document.branding)
|
||||||
|
return etag
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
|
if (it is CancellationException) throw it
|
||||||
// A failed speculative refresh is ordinary offline behaviour. The active
|
// A failed speculative refresh is ordinary offline behaviour. The active
|
||||||
// bundled/cached document remains complete and no screen needs to know.
|
// bundled/cached document remains complete and no screen needs to know.
|
||||||
Log.d("MembyRemoteConfig", "Remote configuration refresh skipped", it)
|
Log.d("MembyRemoteConfig", "Remote configuration refresh skipped", it)
|
||||||
}
|
}.getOrNull() ?: cachedEtag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshBranding(branding: RemoteBranding) {
|
||||||
|
val rawUrl = branding.markUrl.trim()
|
||||||
|
if (rawUrl.isBlank()) return
|
||||||
|
val uri = runCatching { URI(rawUrl) }.getOrNull() ?: return
|
||||||
|
if (uri.scheme != "https" || uri.host.isNullOrBlank()) return
|
||||||
|
val version = branding.markVersion.trim()
|
||||||
|
val key = "mark_etag"
|
||||||
|
val request = Request.Builder().url(rawUrl + if (version.isBlank()) "" else if (rawUrl.contains('?')) "&v=$version" else "?v=$version")
|
||||||
|
.header("Accept", "image/png,image/webp,image/jpeg")
|
||||||
|
.apply { preferences.getString(key, null)?.let { header("If-None-Match", it) } }
|
||||||
|
.build()
|
||||||
|
runCatching {
|
||||||
|
HttpStack.base.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 304) return
|
||||||
|
val contentType = response.body?.contentType()?.toString()?.lowercase().orEmpty()
|
||||||
|
if (!response.isSuccessful || response.body == null ||
|
||||||
|
(contentType != "image/png" && contentType != "image/webp" && contentType != "image/jpeg") ||
|
||||||
|
response.body!!.contentLength() > 2L * 1024L * 1024L) return
|
||||||
|
val bytes = response.body!!.bytes()
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth !in 32..1024 || bounds.outHeight !in 32..1024) return
|
||||||
|
val temporary = File(appContext.cacheDir, "memby_mark.remote.tmp")
|
||||||
|
temporary.writeBytes(bytes)
|
||||||
|
if (markFile.exists()) markFile.delete()
|
||||||
|
if (!temporary.renameTo(markFile)) return
|
||||||
|
preferences.edit().putString(key, response.header("ETag")).apply()
|
||||||
|
_markPath.value = markFile.absolutePath
|
||||||
|
}
|
||||||
|
}.onFailure { Log.d("MembyRemoteConfig", "Remote mark refresh skipped", it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val SUPPORTED_REMOTE_COMPONENTS = setOf("mediaRow", "mediaGrid", "genreBrowser", "hero")
|
||||||
|
|
||||||
internal fun decodeRemoteConfig(raw: String): MembyRemoteConfig? = runCatching {
|
internal fun decodeRemoteConfig(raw: String): MembyRemoteConfig? = runCatching {
|
||||||
remoteConfigJson.decodeFromString<MembyRemoteConfig>(raw)
|
remoteConfigJson.decodeFromString<MembyRemoteConfig>(raw)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
@@ -252,6 +424,25 @@ internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: Strin
|
|||||||
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
|
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
|
||||||
return "unsafe presentation values"
|
return "unsafe presentation values"
|
||||||
}
|
}
|
||||||
|
val sections = document.home.sections +
|
||||||
|
document.movies.sections + document.tv.sections + document.recommendations.sections
|
||||||
|
if (sections.size > 128 || sections.any { it.isBlank() || it != it.trim() || it.length > 64 }) {
|
||||||
|
return "unsafe section ordering"
|
||||||
|
}
|
||||||
|
if (document.features.flags.keys.any { it.isBlank() || it.length > 64 } ||
|
||||||
|
document.experimental.keys.any { it.isBlank() || it.length > 64 }) {
|
||||||
|
return "unsafe feature flag"
|
||||||
|
}
|
||||||
|
if (document.continueWatching.maxItems !in 1..100 ||
|
||||||
|
document.forYou.maxRows !in 0..20 ||
|
||||||
|
document.forYou.refreshHours !in 1..168 ||
|
||||||
|
document.search.maxResults !in 1..200) {
|
||||||
|
return "unsafe configuration limits"
|
||||||
|
}
|
||||||
|
if (document.ui.artworkStyle !in setOf("automatic", "poster", "backdrop") ||
|
||||||
|
document.ui.cardDensity !in setOf("standard", "compact", "large")) {
|
||||||
|
return "unsafe UI preferences"
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ import com.ponzischeme89.memby.ServiceLocator
|
|||||||
import com.ponzischeme89.memby.data.Settings
|
import com.ponzischeme89.memby.data.Settings
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
|
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||||
|
import com.ponzischeme89.memby.data.remoteconfig.RemoteContinueWatching
|
||||||
|
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||||
import com.ponzischeme89.memby.ui.detail.AiringNotice
|
import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
|
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
|
||||||
@@ -438,22 +441,37 @@ internal fun FocusedQuickActionsOverlay(
|
|||||||
* and whatever it grows next) is always shown, since the user never opted out of a row
|
* and whatever it grows next) is always shown, since the user never opted out of a row
|
||||||
* that did not exist when they last opened Settings.
|
* that did not exist when they last opened Settings.
|
||||||
*/
|
*/
|
||||||
internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBrowseRow> {
|
internal fun serverHomeRows(
|
||||||
|
state: HomeUiState,
|
||||||
|
settings: Settings,
|
||||||
|
remoteConfig: MembyRemoteConfig = BundledRemoteConfig.value,
|
||||||
|
): List<HomeBrowseRow> {
|
||||||
val enabledSections = settings.homeSections.split(',').map(String::trim).toSet()
|
val enabledSections = settings.homeSections.split(',').map(String::trim).toSet()
|
||||||
|
val continueEnabled = remoteConfig.continueWatching.enabled &&
|
||||||
|
remoteConfig.featureEnabled("continue_watching", true)
|
||||||
|
val forYouEnabled = remoteConfig.home.showForYou && remoteConfig.forYou.enabled &&
|
||||||
|
remoteConfig.featureEnabled("for_you", true)
|
||||||
val stillLoading = state.loading.isNotEmpty()
|
val stillLoading = state.loading.isNotEmpty()
|
||||||
|
|
||||||
val mapped = state.rows
|
val mapped = state.rows
|
||||||
.foldNextUpIntoContinue()
|
.foldNextUpIntoContinue()
|
||||||
.filter { row ->
|
.filter { row ->
|
||||||
when (row.kind) {
|
when (row.kind) {
|
||||||
"continue", "nextup" -> "continue" in enabledSections
|
"continue", "nextup" ->
|
||||||
|
continueEnabled && "continue" in enabledSections
|
||||||
"favorites" -> "favorites" in enabledSections
|
"favorites" -> "favorites" in enabledSections
|
||||||
"latest" -> "latest" in enabledSections
|
"latest" -> "latest" in enabledSections
|
||||||
|
"recommendations", "recommendation" -> remoteConfig.recommendations.enabled
|
||||||
// The hero row is the four featured cards. It is drawn above the shelves
|
// The hero row is the four featured cards. It is drawn above the shelves
|
||||||
// by HomeMovieHero, so letting it through here would print the same four
|
// by HomeMovieHero, so letting it through here would print the same four
|
||||||
// titles a second time as an unnamed row of posters directly beneath it.
|
// titles a second time as an unnamed row of posters directly beneath it.
|
||||||
SERVER_HERO_ROW_KIND -> false
|
SERVER_HERO_ROW_KIND -> false
|
||||||
else -> true
|
else -> {
|
||||||
|
val forYouRow = row.id.startsWith("for-you:") || row.id == "for-you"
|
||||||
|
val recommendationRow = row.id == "recommended"
|
||||||
|
(!forYouRow || forYouEnabled) &&
|
||||||
|
(!recommendationRow || remoteConfig.recommendations.enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map { row ->
|
.map { row ->
|
||||||
@@ -464,7 +482,9 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
|
|||||||
} else {
|
} else {
|
||||||
row.title
|
row.title
|
||||||
},
|
},
|
||||||
items = if (row.kind == "schedule" || row.kind == "movie-schedule") {
|
items = if (row.kind == "continue") {
|
||||||
|
continueWatchingItems(row.items, remoteConfig.continueWatching)
|
||||||
|
} else if (row.kind == "schedule" || row.kind == "movie-schedule") {
|
||||||
row.items.sortedByScheduleDate()
|
row.items.sortedByScheduleDate()
|
||||||
} else {
|
} else {
|
||||||
row.items
|
row.items
|
||||||
@@ -494,9 +514,40 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// A server response is already context-ranked. Do not reapply the static bundled
|
||||||
|
// composition here: doing so would undo the gateway's per-user ordering. The fallback
|
||||||
|
// path below still uses the cached section definitions when no server rows arrived.
|
||||||
return applyHomeRowPreferences(mapped, settings)
|
return applyHomeRowPreferences(mapped, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val KNOWN_REMOTE_SECTION_COMPONENTS = setOf("mediaRow", "mediaGrid", "genreBrowser")
|
||||||
|
|
||||||
|
internal fun continueWatchingItems(
|
||||||
|
items: List<BaseItem>,
|
||||||
|
config: RemoteContinueWatching,
|
||||||
|
): List<BaseItem> = items
|
||||||
|
.filterNot { item ->
|
||||||
|
!config.includeNextUp && item.isEpisode && item.userData?.played != true &&
|
||||||
|
(item.userData?.playbackPositionTicks ?: 0L) <= 0L
|
||||||
|
}
|
||||||
|
.take(config.maxItems.coerceAtLeast(1))
|
||||||
|
|
||||||
|
/** Applies household ordering while preserving rows unknown to this APK. */
|
||||||
|
internal fun applyRemoteHomeSections(
|
||||||
|
rows: List<HomeBrowseRow>,
|
||||||
|
sections: List<String>,
|
||||||
|
): List<HomeBrowseRow> {
|
||||||
|
val order = sections.map(String::trim).filter(String::isNotEmpty)
|
||||||
|
if (order.isEmpty()) return rows
|
||||||
|
fun rank(row: HomeBrowseRow): Int = order.indexOfFirst { section ->
|
||||||
|
row.id == section || row.id.startsWith("$section:") ||
|
||||||
|
row.kind.name.equals(section, ignoreCase = true)
|
||||||
|
}.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||||
|
return rows.withIndex()
|
||||||
|
.sortedWith(compareBy<IndexedValue<HomeBrowseRow>>({ rank(it.value) }, { it.index }))
|
||||||
|
.map(IndexedValue<HomeBrowseRow>::value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continue Watching and Next Up are one row. This gateway no longer sends a `nextup` row,
|
* Continue Watching and Next Up are one row. This gateway no longer sends a `nextup` row,
|
||||||
* but two things still do: the home cache written by the previous build, which is what a
|
* but two things still do: the home cache written by the previous build, which is what a
|
||||||
@@ -561,11 +612,12 @@ internal fun homeRowsFor(
|
|||||||
destination: BrowseDestination,
|
destination: BrowseDestination,
|
||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
remoteConfig: MembyRemoteConfig = BundledRemoteConfig.value,
|
||||||
): List<HomeBrowseRow> {
|
): List<HomeBrowseRow> {
|
||||||
val continueRow = HomeBrowseRow(
|
val continueRow = HomeBrowseRow(
|
||||||
id = "continue",
|
id = "continue",
|
||||||
title = "Continue Watching",
|
title = "Continue Watching",
|
||||||
items = state.continueWatching,
|
items = continueWatchingItems(state.continueWatching, remoteConfig.continueWatching),
|
||||||
kind = MediaRowKind.CONTINUE,
|
kind = MediaRowKind.CONTINUE,
|
||||||
loading = HomeSection.CONTINUE in state.loading,
|
loading = HomeSection.CONTINUE in state.loading,
|
||||||
emptyMessage = "Nothing in progress",
|
emptyMessage = "Nothing in progress",
|
||||||
@@ -593,7 +645,7 @@ internal fun homeRowsFor(
|
|||||||
// The gateway composes the home screen — including rows this app has no concept
|
// The gateway composes the home screen — including rows this app has no concept
|
||||||
// of, like "Because you watched …" — so when it sends rows, they win.
|
// of, like "Because you watched …" — so when it sends rows, they win.
|
||||||
BrowseDestination.HOME -> if (state.rows.isNotEmpty()) {
|
BrowseDestination.HOME -> if (state.rows.isNotEmpty()) {
|
||||||
serverHomeRows(state, settings)
|
serverHomeRows(state, settings, remoteConfig)
|
||||||
// Genre/studio shelves have dedicated Movies and TV destinations. Home
|
// Genre/studio shelves have dedicated Movies and TV destinations. Home
|
||||||
// keeps personalised, current and new-release rows so each screen has a
|
// keeps personalised, current and new-release rows so each screen has a
|
||||||
// genuinely different browsing character.
|
// genuinely different browsing character.
|
||||||
@@ -612,7 +664,7 @@ internal fun homeRowsFor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BrowseDestination.MOVIES -> serverHomeRows(state, settings)
|
BrowseDestination.MOVIES -> serverHomeRows(state, settings, remoteConfig)
|
||||||
// Movie discovery is authored by the server from this profile's genre and
|
// Movie discovery is authored by the server from this profile's genre and
|
||||||
// studio affinity. Broad latest/library dumps belong on Home, not here.
|
// studio affinity. Broad latest/library dumps belong on Home, not here.
|
||||||
.filter { row ->
|
.filter { row ->
|
||||||
@@ -643,7 +695,7 @@ internal fun homeRowsFor(
|
|||||||
title = "Favourite Shows",
|
title = "Favourite Shows",
|
||||||
items = state.favorites.filter { it.isSeries || it.isEpisode },
|
items = state.favorites.filter { it.isSeries || it.isEpisode },
|
||||||
),
|
),
|
||||||
) + serverHomeRows(state, settings)
|
) + serverHomeRows(state, settings, remoteConfig)
|
||||||
// These shelves are authored and ordered by the per-user recommendation
|
// These shelves are authored and ordered by the per-user recommendation
|
||||||
// engine. Keep their server order: it is the user's affinity ranking.
|
// engine. Keep their server order: it is the user's affinity ranking.
|
||||||
.filter { row ->
|
.filter { row ->
|
||||||
@@ -671,13 +723,44 @@ internal fun homeRowsFor(
|
|||||||
} else {
|
} else {
|
||||||
deduplicated
|
deduplicated
|
||||||
}
|
}
|
||||||
return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
|
val sectionOrder = when (destination) {
|
||||||
applyHomeRowPreferences(populated, settings)
|
BrowseDestination.HOME -> remoteSectionOrder(remoteConfig.home.sectionDefinitions, remoteConfig.home.sections)
|
||||||
|
BrowseDestination.MOVIES -> remoteSectionOrder(remoteConfig.movies.sectionDefinitions, remoteConfig.movies.sections)
|
||||||
|
BrowseDestination.SHOWS -> remoteSectionOrder(remoteConfig.tv.sectionDefinitions, remoteConfig.tv.sections)
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
val continueEnabled = remoteConfig.continueWatching.enabled &&
|
||||||
|
remoteConfig.featureEnabled("continue_watching", true)
|
||||||
|
val remoteOrdered = if (destination == BrowseDestination.HOME) {
|
||||||
|
applyRemoteHomeSections(
|
||||||
|
populated.filterNot { it.id == "continue" && !continueEnabled },
|
||||||
|
sectionOrder,
|
||||||
|
)
|
||||||
|
} else if (sectionOrder.isNotEmpty()) {
|
||||||
|
applyRemoteHomeSections(
|
||||||
|
populated.filterNot { it.id.startsWith("continue") && !continueEnabled },
|
||||||
|
sectionOrder,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
populated
|
populated.filterNot { it.id.startsWith("continue") && !continueEnabled }
|
||||||
|
}
|
||||||
|
return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
|
||||||
|
applyHomeRowPreferences(remoteOrdered, settings)
|
||||||
|
} else {
|
||||||
|
remoteOrdered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun remoteSectionOrder(
|
||||||
|
definitions: List<com.ponzischeme89.memby.data.remoteconfig.RemoteSectionDefinition>,
|
||||||
|
fallback: List<String>,
|
||||||
|
): List<String> = definitions.asSequence()
|
||||||
|
.filter { it.enabled && it.component in KNOWN_REMOTE_SECTION_COMPONENTS }
|
||||||
|
.sortedBy { it.position }
|
||||||
|
.map { it.id }
|
||||||
|
.toList()
|
||||||
|
.ifEmpty { fallback }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A card gets one place on a screen. Episodes collapse to their parent series so a show
|
* A card gets one place on a screen. Episodes collapse to their parent series so a show
|
||||||
* in Continue Watching cannot immediately reappear as a genre recommendation, and live
|
* in Continue Watching cannot immediately reappear as a genre recommendation, and live
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ internal fun HomeScreen(
|
|||||||
remoteConfig: MembyRemoteConfig,
|
remoteConfig: MembyRemoteConfig,
|
||||||
) {
|
) {
|
||||||
val repo = ServiceLocator.repository
|
val repo = ServiceLocator.repository
|
||||||
|
val remoteMarkPath by ServiceLocator.remoteConfig.markPathFlow.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val factory = remember(repo) { HomeViewModelFactory(repo) }
|
val factory = remember(repo) { HomeViewModelFactory(repo) }
|
||||||
@@ -748,7 +749,7 @@ internal fun HomeScreen(
|
|||||||
val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) {
|
val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) {
|
||||||
forYouBrowseRows(forYouState)
|
forYouBrowseRows(forYouState)
|
||||||
} else {
|
} else {
|
||||||
homeRowsFor(selectedDestination, homeContent, settings)
|
homeRowsFor(selectedDestination, homeContent, settings, remoteConfig)
|
||||||
}
|
}
|
||||||
applyWatchedVisibility(destinationRows, settings.hideWatchedMovies)
|
applyWatchedVisibility(destinationRows, settings.hideWatchedMovies)
|
||||||
}
|
}
|
||||||
@@ -926,6 +927,7 @@ internal fun HomeScreen(
|
|||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
TvNavigationRail(
|
TvNavigationRail(
|
||||||
config = remoteConfig.navigation,
|
config = remoteConfig.navigation,
|
||||||
|
markPath = remoteMarkPath,
|
||||||
selected = if (userSwitcherVisible) {
|
selected = if (userSwitcherVisible) {
|
||||||
BrowseDestination.PROFILES
|
BrowseDestination.PROFILES
|
||||||
} else {
|
} else {
|
||||||
@@ -2339,6 +2341,7 @@ internal fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
TvNavigationRail(
|
TvNavigationRail(
|
||||||
config = remoteConfig.navigation,
|
config = remoteConfig.navigation,
|
||||||
|
markPath = remoteMarkPath,
|
||||||
selected = if (userSwitcherVisible) {
|
selected = if (userSwitcherVisible) {
|
||||||
BrowseDestination.PROFILES
|
BrowseDestination.PROFILES
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.ponzischeme89.memby.ui
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.performance.PerformanceMonitor
|
import com.ponzischeme89.memby.performance.PerformanceMonitor
|
||||||
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
||||||
@@ -13,10 +15,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Captured before composition and never observed as state: a downloaded document
|
|
||||||
// is for the next process, never a label change under the viewer's focus.
|
|
||||||
val remoteConfig = ServiceLocator.remoteConfig.active
|
|
||||||
setContent {
|
setContent {
|
||||||
|
val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle()
|
||||||
MembyTheme {
|
MembyTheme {
|
||||||
AppRoot(
|
AppRoot(
|
||||||
remoteConfig = remoteConfig,
|
remoteConfig = remoteConfig,
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ data class ResumableMediaCardModel(
|
|||||||
val primaryUrl: String? = null,
|
val primaryUrl: String? = null,
|
||||||
val played: Boolean = false,
|
val played: Boolean = false,
|
||||||
val favourite: Boolean = false,
|
val favourite: Boolean = false,
|
||||||
|
val isNextUp: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
|
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
|
||||||
val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
|
val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
|
||||||
|
val showProgressTrack: Boolean get() = progress > 0f || isNextUp
|
||||||
|
val nextUpLabel: String? get() = "Next up".takeIf { isNextUp }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun BaseItem.toResumableMediaCardModel(
|
internal fun BaseItem.toResumableMediaCardModel(
|
||||||
@@ -77,6 +80,8 @@ internal fun BaseItem.toResumableMediaCardModel(
|
|||||||
): ResumableMediaCardModel {
|
): ResumableMediaCardModel {
|
||||||
val episodeName = name.trim().takeIf(String::isNotEmpty)
|
val episodeName = name.trim().takeIf(String::isNotEmpty)
|
||||||
val seriesTitle = seriesName?.trim().orEmpty()
|
val seriesTitle = seriesName?.trim().orEmpty()
|
||||||
|
val playbackPosition = userData?.playbackPositionTicks
|
||||||
|
val played = userData?.played == true
|
||||||
return ResumableMediaCardModel(
|
return ResumableMediaCardModel(
|
||||||
id = id,
|
id = id,
|
||||||
title = if (isEpisode) {
|
title = if (isEpisode) {
|
||||||
@@ -87,12 +92,16 @@ internal fun BaseItem.toResumableMediaCardModel(
|
|||||||
episodeName = episodeName.takeIf { isEpisode && it != seriesTitle },
|
episodeName = episodeName.takeIf { isEpisode && it != seriesTitle },
|
||||||
seasonNumber = parentIndexNumber.takeIf { isEpisode },
|
seasonNumber = parentIndexNumber.takeIf { isEpisode },
|
||||||
episodeNumber = indexNumber.takeIf { isEpisode },
|
episodeNumber = indexNumber.takeIf { isEpisode },
|
||||||
playbackPositionTicks = userData?.playbackPositionTicks,
|
playbackPositionTicks = playbackPosition,
|
||||||
runtimeTicks = runTimeTicks,
|
runtimeTicks = runTimeTicks,
|
||||||
backdropUrl = backdropUrl,
|
backdropUrl = backdropUrl,
|
||||||
primaryUrl = primaryUrl,
|
primaryUrl = primaryUrl,
|
||||||
played = userData?.played == true,
|
played = played,
|
||||||
favourite = isFavorite,
|
favourite = isFavorite,
|
||||||
|
// This adapter is used by the Continue Watching card. Within that merged row, an
|
||||||
|
// unplayed episode with no playhead is the Next Up half; resumable episodes have a
|
||||||
|
// positive playhead and films are never supplied by Emby's Next Up feed.
|
||||||
|
isNextUp = isEpisode && !played && (playbackPosition ?: 0L) <= 0L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,12 +177,21 @@ fun ResumableMediaCard(
|
|||||||
delay(MINUTE_MILLIS - now % MINUTE_MILLIS)
|
delay(MINUTE_MILLIS - now % MINUTE_MILLIS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val finishAt = remember(nowEpochMillis, model.playbackPositionTicks, model.runtimeTicks) {
|
val finishAt = remember(
|
||||||
expectedFinishEpochMillis(
|
nowEpochMillis,
|
||||||
nowEpochMillis = nowEpochMillis,
|
model.isNextUp,
|
||||||
positionTicks = model.playbackPositionTicks,
|
model.playbackPositionTicks,
|
||||||
runtimeTicks = model.runtimeTicks,
|
model.runtimeTicks,
|
||||||
)
|
) {
|
||||||
|
if (model.isNextUp) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
expectedFinishEpochMillis(
|
||||||
|
nowEpochMillis = nowEpochMillis,
|
||||||
|
positionTicks = model.playbackPositionTicks,
|
||||||
|
runtimeTicks = model.runtimeTicks,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val endsAt = remember(finishAt, context) {
|
val endsAt = remember(finishAt, context) {
|
||||||
finishAt?.let {
|
finishAt?.let {
|
||||||
@@ -184,11 +202,12 @@ fun ResumableMediaCard(
|
|||||||
"Ends at: $localTime"
|
"Ends at: $localTime"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val description = remember(model, endsAt) {
|
val progressLabel = model.nextUpLabel ?: endsAt
|
||||||
|
val description = remember(model, progressLabel) {
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
model.title.takeIf(String::isNotBlank),
|
model.title.takeIf(String::isNotBlank),
|
||||||
model.episodeLabel,
|
model.episodeLabel,
|
||||||
endsAt,
|
progressLabel,
|
||||||
model.progress.takeIf { it > 0f }?.let { "${(it * 100).toInt()} percent watched" },
|
model.progress.takeIf { it > 0f }?.let { "${(it * 100).toInt()} percent watched" },
|
||||||
).joinToString(", ")
|
).joinToString(", ")
|
||||||
}
|
}
|
||||||
@@ -256,7 +275,7 @@ fun ResumableMediaCard(
|
|||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(30.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (model.progress > 0f) {
|
if (model.showProgressTrack) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
@@ -264,12 +283,14 @@ fun ResumableMediaCard(
|
|||||||
.height(5.dp)
|
.height(5.dp)
|
||||||
.background(Color.Black.copy(alpha = 0.65f)),
|
.background(Color.Black.copy(alpha = 0.65f)),
|
||||||
) {
|
) {
|
||||||
Box(
|
if (model.progress > 0f) {
|
||||||
Modifier
|
Box(
|
||||||
.fillMaxWidth(model.progress)
|
Modifier
|
||||||
.height(5.dp)
|
.fillMaxWidth(model.progress)
|
||||||
.background(MembyAccent),
|
.height(5.dp)
|
||||||
)
|
.background(MembyAccent),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (model.played || model.favourite) {
|
if (model.played || model.favourite) {
|
||||||
@@ -312,7 +333,7 @@ fun ResumableMediaCard(
|
|||||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
endsAt?.let { label ->
|
progressLabel?.let { label ->
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
color = MembyQuietText,
|
color = MembyQuietText,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import coil.compose.AsyncImage
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -168,6 +169,7 @@ fun TvNavigationRail(
|
|||||||
* beside screens that have nowhere to put a menu.
|
* beside screens that have nowhere to put a menu.
|
||||||
*/
|
*/
|
||||||
onUserLongPressed: (() -> Unit)? = null,
|
onUserLongPressed: (() -> Unit)? = null,
|
||||||
|
markPath: String? = null,
|
||||||
) {
|
) {
|
||||||
var railHasFocus by remember { mutableStateOf(false) }
|
var railHasFocus by remember { mutableStateOf(false) }
|
||||||
val logoScale = remember { Animatable(0.72f) }
|
val logoScale = remember { Animatable(0.72f) }
|
||||||
@@ -243,8 +245,8 @@ fun TvNavigationRail(
|
|||||||
// It carries its own colour and is deliberately not tinted — this is the
|
// It carries its own colour and is deliberately not tinted — this is the
|
||||||
// app's icon, the same one the launcher on the television shows, so a
|
// app's icon, the same one the launcher on the television shows, so a
|
||||||
// seasonal palette repainting it would make it a different mark.
|
// seasonal palette repainting it would make it a different mark.
|
||||||
Image(
|
if (markPath != null) AsyncImage(
|
||||||
painter = painterResource(R.drawable.memby_mark),
|
model = markPath,
|
||||||
contentDescription = "Memby",
|
contentDescription = "Memby",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(30.dp)
|
.size(30.dp)
|
||||||
@@ -254,6 +256,12 @@ fun TvNavigationRail(
|
|||||||
alpha = logoAlpha.value
|
alpha = logoAlpha.value
|
||||||
rotationZ = logoRotation.value
|
rotationZ = logoRotation.value
|
||||||
},
|
},
|
||||||
|
) else Image(
|
||||||
|
painter = painterResource(R.drawable.memby_mark),
|
||||||
|
contentDescription = "Memby",
|
||||||
|
modifier = Modifier.size(30.dp).graphicsLayer {
|
||||||
|
scaleX = logoScale.value; scaleY = logoScale.value; alpha = logoAlpha.value; rotationZ = logoRotation.value
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
Column(verticalArrangement = Arrangement.Center) {
|
Column(verticalArrangement = Arrangement.Center) {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -24,6 +24,15 @@ class MembyRemoteConfigTest {
|
|||||||
assertEquals(false, document.navigation.showVersion)
|
assertEquals(false, document.navigation.showVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun olderDocumentReceivesSafeDefaultsForNewSections() {
|
||||||
|
val document = decodeRemoteConfig(validDocument)!!
|
||||||
|
|
||||||
|
assertEquals(true, document.continueWatching.enabled)
|
||||||
|
assertEquals(50, document.search.maxResults)
|
||||||
|
assertEquals("automatic", document.ui.artworkStyle)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun incompatibleSchemaAndAppVersionsAreRejected() {
|
fun incompatibleSchemaAndAppVersionsAreRejected() {
|
||||||
val document = decodeRemoteConfig(validDocument)!!
|
val document = decodeRemoteConfig(validDocument)!!
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ResumableMediaCardTest {
|
class ResumableMediaCardTest {
|
||||||
@@ -60,6 +63,48 @@ class ResumableMediaCardTest {
|
|||||||
assertEquals(0f, resumableProgress(500L, null))
|
assertEquals(0f, resumableProgress(500L, null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unstarted episode in continue watching is next up`() {
|
||||||
|
val model = BaseItem(
|
||||||
|
id = "next",
|
||||||
|
name = "The Next Episode",
|
||||||
|
type = "Episode",
|
||||||
|
userData = UserItemData(playbackPositionTicks = 0L),
|
||||||
|
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||||
|
|
||||||
|
assertTrue(model.isNextUp)
|
||||||
|
assertEquals(0f, model.progress)
|
||||||
|
assertTrue(model.showProgressTrack)
|
||||||
|
assertEquals("Next up", model.nextUpLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a started episode is resumable rather than next up`() {
|
||||||
|
val model = BaseItem(
|
||||||
|
id = "resume",
|
||||||
|
name = "The Current Episode",
|
||||||
|
type = "Episode",
|
||||||
|
userData = UserItemData(playbackPositionTicks = 10_000L),
|
||||||
|
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||||
|
|
||||||
|
assertFalse(model.isNextUp)
|
||||||
|
assertNull(model.nextUpLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unstarted film is not next up`() {
|
||||||
|
val model = BaseItem(
|
||||||
|
id = "film",
|
||||||
|
name = "A Film",
|
||||||
|
type = "Movie",
|
||||||
|
userData = UserItemData(playbackPositionTicks = 0L),
|
||||||
|
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||||
|
|
||||||
|
assertFalse(model.isNextUp)
|
||||||
|
assertFalse(model.showProgressTrack)
|
||||||
|
assertNull(model.nextUpLabel)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MILLIS_PER_SECOND = 1_000L
|
const val MILLIS_PER_SECOND = 1_000L
|
||||||
const val TICKS_PER_MILLISECOND_FOR_TEST = 10_000L
|
const val TICKS_PER_MILLISECOND_FOR_TEST = 10_000L
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.ponzischeme89.memby.data.Settings
|
|||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
import com.ponzischeme89.memby.data.model.UserItemData
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
|
import com.ponzischeme89.memby.data.remoteconfig.RemoteContinueWatching
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -16,6 +17,31 @@ import org.junit.Test
|
|||||||
* the server invented, and surviving a cold start from cache.
|
* the server invented, and surviving a cold start from cache.
|
||||||
*/
|
*/
|
||||||
class ServerHomeRowsTest {
|
class ServerHomeRowsTest {
|
||||||
|
@Test
|
||||||
|
fun `remote home configuration orders rows and controls next up`() {
|
||||||
|
val nextUp = BaseItem(
|
||||||
|
id = "next", name = "Next", type = "Episode",
|
||||||
|
userData = UserItemData(playbackPositionTicks = 0L),
|
||||||
|
)
|
||||||
|
val resume = BaseItem(
|
||||||
|
id = "resume", name = "Resume", type = "Movie",
|
||||||
|
userData = UserItemData(playbackPositionTicks = 10L),
|
||||||
|
)
|
||||||
|
val rows = listOf(
|
||||||
|
HomeBrowseRow("latest-movies", "Latest", emptyList(), MediaRowKind.MOVIES, emptyMessage = ""),
|
||||||
|
HomeBrowseRow("continue", "Continue", listOf(resume, nextUp), MediaRowKind.CONTINUE, emptyMessage = ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("continue", "latest-movies"), applyRemoteHomeSections(rows, listOf("continue", "latest-movies")).map { it.id })
|
||||||
|
assertEquals(
|
||||||
|
listOf("resume"),
|
||||||
|
continueWatchingItems(
|
||||||
|
listOf(resume, nextUp),
|
||||||
|
RemoteContinueWatching(includeNextUp = false),
|
||||||
|
).map(BaseItem::id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `profile row preferences hide pin and order server rows`() {
|
fun `profile row preferences hide pin and order server rows`() {
|
||||||
val serverRows = listOf(
|
val serverRows = listOf(
|
||||||
|
|||||||
+214
-14
@@ -8,6 +8,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/config"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,41 @@ type featureDefinition struct {
|
|||||||
Recovery string `json:"recovery"`
|
Recovery string `json:"recovery"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configurationDefinition is the shared catalogue for booleans and behavioural
|
||||||
|
// values. Scope is part of the contract so new features do not grow bespoke settings.
|
||||||
|
type configurationDefinition struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
Default any `json:"default"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
Min *int `json:"min,omitempty"`
|
||||||
|
Max *int `json:"max,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var configurationCatalogue = []configurationDefinition{
|
||||||
|
{Key: "forYou.enabled", Name: "For You", Description: "Show personalised recommendations on Home.", Type: "boolean", Scopes: []string{"global", "user", "device", "experimental"}, Default: true},
|
||||||
|
{Key: "continueWatching.enabled", Name: "Continue Watching", Description: "Show the Continue Watching row.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||||
|
{Key: "continueWatching.showNextUp", Name: "Continue Watching: Next Up", Description: "Include an unstarted next episode in Continue Watching.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||||
|
{Key: "continueWatching.progressColour", Name: "Progress bar colour", Description: "Choose the progress bar treatment.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "emby", Options: []string{"emby", "white"}},
|
||||||
|
{Key: "ratings.enabled", Name: "Ratings", Description: "Show ratings throughout the catalogue.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||||
|
{Key: "genres.enabled", Name: "Genres", Description: "Show genre browsing controls.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||||
|
{Key: "trailers.enabled", Name: "Trailers", Description: "Offer trailers where available.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
|
||||||
|
{Key: "requests.enabled", Name: "Requests", Description: "Allow title requests from viewers.", Type: "boolean", Scopes: []string{"global", "user"}, Default: true},
|
||||||
|
{Key: "hero.enabled", Name: "Hero", Description: "Show the Home hero presentation.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
|
||||||
|
{Key: "home.heroRefreshSeconds", Name: "Hero refresh interval", Description: "Seconds between hero refreshes.", Type: "integer", Scopes: []string{"global", "device"}, Default: 60, Min: intPtr(15), Max: intPtr(3600)},
|
||||||
|
{Key: "home.maxItemsPerRow", Name: "Maximum items per row", Description: "Maximum number of cards shown in a row.", Type: "integer", Scopes: []string{"global", "device"}, Default: 20, Min: intPtr(1), Max: intPtr(100)},
|
||||||
|
{Key: "home.sectionDefinitions", Name: "Home page sections", Description: "JSON section definitions controlling Home composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Home.SectionDefinitions},
|
||||||
|
{Key: "movies.sectionDefinitions", Name: "Movies page sections", Description: "JSON section definitions controlling Movies composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Movies.SectionDefinitions},
|
||||||
|
{Key: "tv.sectionDefinitions", Name: "TV page sections", Description: "JSON section definitions controlling TV composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().TV.SectionDefinitions},
|
||||||
|
{Key: "branding.markUrl", Name: "Memby mark URL", Description: "HTTPS image used for the TV rail mark; the bundled mark remains the fallback.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
|
||||||
|
{Key: "branding.markVersion", Name: "Memby mark version", Description: "Cache-busting version for the configured mark.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(v int) *int { return &v }
|
||||||
|
|
||||||
var featureCatalogue = []featureDefinition{
|
var featureCatalogue = []featureDefinition{
|
||||||
{
|
{
|
||||||
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
|
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
|
||||||
@@ -191,12 +227,66 @@ type evaluatedFeature struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type featureResponse struct {
|
type featureResponse struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
Revision int64 `json:"revision"`
|
Revision int64 `json:"revision"`
|
||||||
SafeMode bool `json:"safeMode"`
|
SafeMode bool `json:"safeMode"`
|
||||||
UpdatedAt any `json:"updatedAt,omitempty"`
|
UpdatedAt any `json:"updatedAt,omitempty"`
|
||||||
CanRollback bool `json:"canRollback"`
|
CanRollback bool `json:"canRollback"`
|
||||||
Features []evaluatedFeature `json:"features"`
|
Features []evaluatedFeature `json:"features"`
|
||||||
|
Configuration []evaluatedConfiguration `json:"configuration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type evaluatedConfiguration struct {
|
||||||
|
configurationDefinition
|
||||||
|
Value any `json:"value"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func configurationDefinitionFor(key string) (configurationDefinition, bool) {
|
||||||
|
for _, definition := range configurationCatalogue {
|
||||||
|
if definition.Key == key {
|
||||||
|
return definition, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configurationDefinition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func configurationValue(policy store.FeaturePolicy, definition configurationDefinition, sessions ...store.Session) (any, string) {
|
||||||
|
if len(sessions) > 0 {
|
||||||
|
session := sessions[0]
|
||||||
|
if values, ok := policy.DeviceValues[session.DeviceID]; ok {
|
||||||
|
if raw, ok := values[definition.Key]; ok {
|
||||||
|
var value any
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
return value, "device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if values, ok := policy.UserValues[session.EmbyUserID]; ok {
|
||||||
|
if raw, ok := values[definition.Key]; ok {
|
||||||
|
var value any
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
return value, "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := policy.Values[definition.Key]; ok {
|
||||||
|
var value any
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
return value, "global"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return definition.Default, "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func configurationPayload(policy store.FeaturePolicy, sessions ...store.Session) []evaluatedConfiguration {
|
||||||
|
result := make([]evaluatedConfiguration, 0, len(configurationCatalogue))
|
||||||
|
for _, definition := range configurationCatalogue {
|
||||||
|
value, source := configurationValue(policy, definition, sessions...)
|
||||||
|
result = append(result, evaluatedConfiguration{configurationDefinition: definition, Value: value, Source: source})
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func knownFeature(key string) (featureDefinition, bool) {
|
func knownFeature(key string) (featureDefinition, bool) {
|
||||||
@@ -213,6 +303,20 @@ func evaluateFeature(policy store.FeaturePolicy, definition featureDefinition, p
|
|||||||
if override, ok := policy.Overrides[definition.Key]; ok {
|
if override, ok := policy.Overrides[definition.Key]; ok {
|
||||||
enabled, source = override, "override"
|
enabled, source = override, "override"
|
||||||
}
|
}
|
||||||
|
// Legacy server call-sites continue to use their stable snake_case keys while
|
||||||
|
// operators edit the canonical typed catalogue.
|
||||||
|
canonical := map[string]string{
|
||||||
|
featureContinueWatching: "continueWatching.enabled",
|
||||||
|
featureGenreBrowser: "genres.enabled",
|
||||||
|
}[definition.Key]
|
||||||
|
if canonical != "" {
|
||||||
|
if raw, ok := policy.Values[canonical]; ok {
|
||||||
|
var value bool
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
enabled, source = value, "configuration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if policy.SafeMode {
|
if policy.SafeMode {
|
||||||
enabled, source = false, "safe_mode"
|
enabled, source = false, "safe_mode"
|
||||||
}
|
}
|
||||||
@@ -249,6 +353,10 @@ func (s *Server) featureEnabled(ctx context.Context, key string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]string) featureResponse {
|
func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]string) featureResponse {
|
||||||
|
return featurePayloadForSession(policy, protocol, nil, capabilities...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func featurePayloadForSession(policy store.FeaturePolicy, protocol int, session *store.Session, capabilities ...[]string) featureResponse {
|
||||||
features := make([]evaluatedFeature, 0, len(featureCatalogue))
|
features := make([]evaluatedFeature, 0, len(featureCatalogue))
|
||||||
for _, definition := range featureCatalogue {
|
for _, definition := range featureCatalogue {
|
||||||
evaluated := evaluateFeature(policy, definition, protocol)
|
evaluated := evaluateFeature(policy, definition, protocol)
|
||||||
@@ -260,11 +368,17 @@ func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]
|
|||||||
}
|
}
|
||||||
features = append(features, evaluated)
|
features = append(features, evaluated)
|
||||||
}
|
}
|
||||||
return featureResponse{
|
response := featureResponse{
|
||||||
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
|
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
|
||||||
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
|
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
|
||||||
CanRollback: policy.Previous != nil, Features: features,
|
CanRollback: policy.Previous != nil, Features: features,
|
||||||
}
|
}
|
||||||
|
if session != nil {
|
||||||
|
response.Configuration = configurationPayload(policy, *session)
|
||||||
|
} else {
|
||||||
|
response.Configuration = configurationPayload(policy)
|
||||||
|
}
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string) map[string]bool {
|
func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string) map[string]bool {
|
||||||
@@ -275,16 +389,20 @@ func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, session store.Session) {
|
||||||
writeJSON(w, http.StatusOK, featurePayload(
|
writeJSON(w, http.StatusOK, featurePayloadForSession(
|
||||||
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), clientCapabilities(r),
|
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), &session, clientCapabilities(r),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
type featurePolicyRequest struct {
|
type featurePolicyRequest struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
ExpectedRevision int64 `json:"expectedRevision"`
|
ExpectedRevision int64 `json:"expectedRevision"`
|
||||||
Overrides map[string]bool `json:"overrides"`
|
Overrides map[string]bool `json:"overrides"`
|
||||||
|
Values map[string]json.RawMessage `json:"values"`
|
||||||
|
UserValues map[string]map[string]json.RawMessage `json:"userValues"`
|
||||||
|
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues"`
|
||||||
|
Experimental map[string]json.RawMessage `json:"experimental"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -300,7 +418,7 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
|||||||
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
|
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next := store.FeaturePolicy{Overrides: map[string]bool{}, SafeMode: current.SafeMode}
|
next := store.FeaturePolicy{Overrides: map[string]bool{}, Values: req.Values, UserValues: req.UserValues, DeviceValues: req.DeviceValues, Experimental: req.Experimental, SafeMode: current.SafeMode}
|
||||||
switch strings.TrimSpace(req.Action) {
|
switch strings.TrimSpace(req.Action) {
|
||||||
case "save":
|
case "save":
|
||||||
for key, enabled := range req.Overrides {
|
for key, enabled := range req.Overrides {
|
||||||
@@ -310,11 +428,17 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
next.Overrides[key] = enabled
|
next.Overrides[key] = enabled
|
||||||
}
|
}
|
||||||
|
if err := validateConfigurationValues(next); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
case "safe-mode":
|
case "safe-mode":
|
||||||
next.Overrides = current.Overrides
|
next.Overrides = current.Overrides
|
||||||
|
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
|
||||||
next.SafeMode = true
|
next.SafeMode = true
|
||||||
case "leave-safe-mode":
|
case "leave-safe-mode":
|
||||||
next.Overrides = current.Overrides
|
next.Overrides = current.Overrides
|
||||||
|
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
|
||||||
next.SafeMode = false
|
next.SafeMode = false
|
||||||
case "reset":
|
case "reset":
|
||||||
next.SafeMode = false
|
next.SafeMode = false
|
||||||
@@ -324,6 +448,7 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.Overrides = current.Previous.Overrides
|
next.Overrides = current.Previous.Overrides
|
||||||
|
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Previous.Values, current.Previous.UserValues, current.Previous.DeviceValues, current.Previous.Experimental
|
||||||
next.SafeMode = current.Previous.SafeMode
|
next.SafeMode = current.Previous.SafeMode
|
||||||
default:
|
default:
|
||||||
writeError(w, http.StatusBadRequest, "unknown feature policy action")
|
writeError(w, http.StatusBadRequest, "unknown feature policy action")
|
||||||
@@ -345,6 +470,81 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, http.StatusOK, featurePayload(stored, ProtocolVersion))
|
writeJSON(w, http.StatusOK, featurePayload(stored, ProtocolVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateConfigurationValues(policy store.FeaturePolicy) error {
|
||||||
|
for key, raw := range policy.Values {
|
||||||
|
definition, ok := configurationDefinitionFor(key)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unknown configuration value: " + key)
|
||||||
|
}
|
||||||
|
if err := validateConfigurationValue(definition, raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for scope, values := range map[string]map[string]json.RawMessage{"experimental": policy.Experimental} {
|
||||||
|
for key, raw := range values {
|
||||||
|
definition, ok := configurationDefinitionFor(key)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unknown configuration value: " + key)
|
||||||
|
}
|
||||||
|
if !slices.Contains(definition.Scopes, scope) {
|
||||||
|
return errors.New("configuration value does not support scope " + scope + ": " + key)
|
||||||
|
}
|
||||||
|
if err := validateConfigurationValue(definition, raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for scope, groups := range map[string]map[string]map[string]json.RawMessage{"user": policy.UserValues, "device": policy.DeviceValues} {
|
||||||
|
for _, values := range groups {
|
||||||
|
for key, raw := range values {
|
||||||
|
definition, ok := configurationDefinitionFor(key)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unknown configuration value: " + key)
|
||||||
|
}
|
||||||
|
if !slices.Contains(definition.Scopes, scope) {
|
||||||
|
return errors.New("configuration value does not support scope " + scope + ": " + key)
|
||||||
|
}
|
||||||
|
if err := validateConfigurationValue(definition, raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfigurationValue(definition configurationDefinition, raw json.RawMessage) error {
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return errors.New("invalid configuration value: " + definition.Key)
|
||||||
|
}
|
||||||
|
switch definition.Type {
|
||||||
|
case "boolean":
|
||||||
|
if _, ok := value.(bool); !ok {
|
||||||
|
return errors.New("configuration value must be boolean: " + definition.Key)
|
||||||
|
}
|
||||||
|
case "integer":
|
||||||
|
n, ok := value.(float64)
|
||||||
|
if !ok || n != float64(int(n)) || (definition.Min != nil && int(n) < *definition.Min) || (definition.Max != nil && int(n) > *definition.Max) {
|
||||||
|
return errors.New("configuration value is outside its allowed range: " + definition.Key)
|
||||||
|
}
|
||||||
|
case "enum":
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok || !slices.Contains(definition.Options, text) {
|
||||||
|
return errors.New("configuration value is not an allowed option: " + definition.Key)
|
||||||
|
}
|
||||||
|
case "json":
|
||||||
|
if _, ok := value.([]any); !ok {
|
||||||
|
return errors.New("configuration value must be a JSON array: " + definition.Key)
|
||||||
|
}
|
||||||
|
case "string":
|
||||||
|
if _, ok := value.(string); !ok {
|
||||||
|
return errors.New("configuration value must be text: " + definition.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseCapabilities(raw string) []string {
|
func parseCapabilities(raw string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
values := []string{}
|
values := []string{}
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ type heroCandidate struct {
|
|||||||
// when none did, which is a different thing from a score of zero.
|
// when none did, which is a different thing from a score of zero.
|
||||||
Rating float64
|
Rating float64
|
||||||
Rated bool
|
Rated bool
|
||||||
|
Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
// heroRecency decays linearly across the window.
|
// heroRecency decays linearly across the window.
|
||||||
@@ -189,6 +190,16 @@ func heroScore(candidate heroCandidate, now time.Time) float64 {
|
|||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func heroSource(candidate heroCandidate) string {
|
||||||
|
if candidate.Source != "" {
|
||||||
|
return candidate.Source
|
||||||
|
}
|
||||||
|
if candidate.Kind == heroSeriesPremiere || candidate.Kind == heroSeasonPremiere {
|
||||||
|
return "continue_world"
|
||||||
|
}
|
||||||
|
return "recommended_for_user"
|
||||||
|
}
|
||||||
|
|
||||||
// rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned
|
// rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned
|
||||||
// about without a network.
|
// about without a network.
|
||||||
//
|
//
|
||||||
@@ -340,6 +351,22 @@ func heroLabel(candidate heroCandidate, now time.Time) string {
|
|||||||
// be empty, and is empty precisely when there is nothing true to say — a card with no
|
// be empty, and is empty precisely when there is nothing true to say — a card with no
|
||||||
// evidence behind it says nothing rather than inventing a reason.
|
// evidence behind it says nothing rather than inventing a reason.
|
||||||
func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string {
|
func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string {
|
||||||
|
if location == nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
local := now.In(location)
|
||||||
|
if candidate.Source == "time_sensitive" {
|
||||||
|
return "Tonight's pick"
|
||||||
|
}
|
||||||
|
if candidate.Source == "continue_world" && local.Hour() >= 20 {
|
||||||
|
return "You normally watch an episode around now"
|
||||||
|
}
|
||||||
|
if candidate.Source == "favourite_genre" && local.Weekday() == time.Sunday && local.Hour() < 18 {
|
||||||
|
return "Something easy for Sunday"
|
||||||
|
}
|
||||||
|
if candidate.Source == "trending" {
|
||||||
|
return "Trending amongst Memby viewers"
|
||||||
|
}
|
||||||
acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating
|
acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating
|
||||||
fresh := heroRecency(candidate.ReleasedAt, now) > 0
|
fresh := heroRecency(candidate.ReleasedAt, now) > 0
|
||||||
switch {
|
switch {
|
||||||
@@ -857,7 +884,7 @@ func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroC
|
|||||||
}
|
}
|
||||||
byID[fact.ID] = heroCandidate{
|
byID[fact.ID] = heroCandidate{
|
||||||
ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw,
|
ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw,
|
||||||
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated,
|
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated, Source: "admin_pinned",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out := make([]heroCandidate, 0, len(ids))
|
out := make([]heroCandidate, 0, len(ids))
|
||||||
@@ -949,6 +976,20 @@ func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]hero
|
|||||||
seen[fact.ID] = true
|
seen[fact.ID] = true
|
||||||
facts[fact.ID] = fact
|
facts[fact.ID] = fact
|
||||||
rating, rated := heroRatingOf(raw)
|
rating, rated := heroRatingOf(raw)
|
||||||
|
source := "recommended_for_user"
|
||||||
|
rowText := strings.ToLower(row.Kind + " " + row.ID)
|
||||||
|
if strings.Contains(rowText, "favorite") {
|
||||||
|
source = "favourite_genre"
|
||||||
|
}
|
||||||
|
if strings.Contains(rowText, "trending") {
|
||||||
|
source = "trending"
|
||||||
|
}
|
||||||
|
if strings.Contains(rowText, "season") {
|
||||||
|
source = "seasonal"
|
||||||
|
}
|
||||||
|
if strings.Contains(rowText, "latest") {
|
||||||
|
source = "new_release"
|
||||||
|
}
|
||||||
candidates = append(candidates, heroCandidate{
|
candidates = append(candidates, heroCandidate{
|
||||||
ID: fact.ID,
|
ID: fact.ID,
|
||||||
Name: fact.Name,
|
Name: fact.Name,
|
||||||
@@ -956,7 +997,7 @@ func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]hero
|
|||||||
Item: raw,
|
Item: raw,
|
||||||
ReleasedAt: fact.Premiere,
|
ReleasedAt: fact.Premiere,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
Rated: rated,
|
Rated: rated, Source: source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1180,7 @@ func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []he
|
|||||||
Item: raw,
|
Item: raw,
|
||||||
ReleasedAt: premiere.AiredAt,
|
ReleasedAt: premiere.AiredAt,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
Rated: rated,
|
Rated: rated, Source: "continue_world",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return candidates
|
return candidates
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type activeHeroResponse struct {
|
type activeHeroResponse struct {
|
||||||
Placement string `json:"placement"`
|
Placement string `json:"placement"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Rows []recommend.Row `json:"rows"`
|
Candidates []activeHeroCandidate `json:"candidates,omitempty"`
|
||||||
|
Rows []recommend.Row `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type activeHeroCandidate struct {
|
||||||
|
ItemID string `json:"itemId"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleActiveHero gives every section the same server-owned resolver as Home. The client
|
// handleActiveHero gives every section the same server-owned resolver as Home. The client
|
||||||
@@ -83,6 +92,9 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
|
|||||||
location := s.heroLocation()
|
location := s.heroLocation()
|
||||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
||||||
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
||||||
|
for index := range scheduled {
|
||||||
|
scheduled[index].Source = "time_sensitive"
|
||||||
|
}
|
||||||
|
|
||||||
var candidates []heroCandidate
|
var candidates []heroCandidate
|
||||||
if placement == store.HeroPlacementTVShows {
|
if placement == store.HeroPlacementTVShows {
|
||||||
@@ -109,9 +121,13 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
items := make([]json.RawMessage, 0, len(ranked))
|
items := make([]json.RawMessage, 0, len(ranked))
|
||||||
|
metadata := make([]activeHeroCandidate, 0, len(ranked))
|
||||||
for index, candidate := range ranked {
|
for index, candidate := range ranked {
|
||||||
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)))
|
reason := heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)
|
||||||
|
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), reason))
|
||||||
|
metadata = append(metadata, activeHeroCandidate{ItemID: candidate.ID, Source: heroSource(candidate), Score: heroScore(candidate, now), Reason: reason, Pinned: candidate.Source == "admin_pinned"})
|
||||||
}
|
}
|
||||||
|
response.Candidates = metadata
|
||||||
response.Rows = append(response.Rows, recommend.Row{ID: "hero-" + placement, Title: "Featured", Kind: heroRowKind, Items: items})
|
response.Rows = append(response.Rows, recommend.Row{ID: "hero-" + placement, Title: "Featured", Kind: heroRowKind, Items: items})
|
||||||
s.loggerFor(ctx).Debug("section hero resolved", "placement", placement, "source", source, "items", len(items))
|
s.loggerFor(ctx).Debug("section hero resolved", "placement", placement, "source", source, "items", len(items))
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ type homeResponse struct {
|
|||||||
// Rows is the home screen as the server wants it drawn: order, titles and kinds all
|
// Rows is the home screen as the server wants it drawn: order, titles and kinds all
|
||||||
// decided here, so a new row (a recommendation strip, a seasonal collection) ships
|
// decided here, so a new row (a recommendation strip, a seasonal collection) ships
|
||||||
// without touching the TV app. The client renders whatever arrives.
|
// without touching the TV app. The client renders whatever arrives.
|
||||||
Rows []recommend.Row `json:"rows"`
|
Rows []recommend.Row `json:"rows"`
|
||||||
|
RowRelevance []homeRowRelevance `json:"rowRelevance,omitempty"`
|
||||||
|
|
||||||
// The fixed rows are also sent flat. They are what the client caches for an
|
// The fixed rows are also sent flat. They are what the client caches for an
|
||||||
// instant cold start, and what the direct-to-Emby path still produces.
|
// instant cold start, and what the direct-to-Emby path still produces.
|
||||||
@@ -67,6 +68,12 @@ type homeResponse struct {
|
|||||||
// answer to another running a different build. The client asks /v1/update instead.
|
// answer to another running a different build. The client asks /v1/update instead.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type homeRowRelevance struct {
|
||||||
|
RowID string `json:"rowId"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// handleHome answers the entire launcher in one round trip.
|
// handleHome answers the entire launcher in one round trip.
|
||||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -369,7 +376,9 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
out.Rows = append(rows, recommendations...)
|
out.Rows = append(rows, recommendations...)
|
||||||
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
|
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
|
||||||
if rowStatsOK {
|
if rowStatsOK {
|
||||||
out.Rows = personalizeHomeRows(out.Rows, rowStats)
|
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, rowStats, now)
|
||||||
|
} else {
|
||||||
|
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, nil, now)
|
||||||
}
|
}
|
||||||
assemble()
|
assemble()
|
||||||
rank := timing.Start(ctx, timing.StageRank)
|
rank := timing.Start(ctx, timing.StageRank)
|
||||||
@@ -528,6 +537,75 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rankHomeRows is the server-side contextual row engine. It deliberately keeps
|
||||||
|
// Continue Watching as the household's reliable first landmark, then scores discovery
|
||||||
|
// shelves using engagement, time context and the row's own data source. New row types can
|
||||||
|
// participate without a client release because only the row metadata is interpreted here.
|
||||||
|
func rankHomeRows(rows []recommend.Row, stats []store.RowStat, now time.Time) ([]recommend.Row, []homeRowRelevance) {
|
||||||
|
byID := make(map[string]store.RowStat, len(stats))
|
||||||
|
for _, stat := range stats {
|
||||||
|
byID[stat.RowID] = stat
|
||||||
|
}
|
||||||
|
type scored struct {
|
||||||
|
row recommend.Row
|
||||||
|
score float64
|
||||||
|
reason string
|
||||||
|
position int
|
||||||
|
}
|
||||||
|
ranked := make([]scored, 0, len(rows))
|
||||||
|
for position, row := range rows {
|
||||||
|
score := 1.0
|
||||||
|
reason := ""
|
||||||
|
id := strings.ToLower(row.ID + " " + row.Kind + " " + row.Title)
|
||||||
|
if stat, ok := byID[row.ID]; ok && stat.Impressions >= 3 {
|
||||||
|
engagement := float64(stat.Selects)*6 + float64(stat.Focuses) + float64(stat.DwellMs)/30_000
|
||||||
|
score += (engagement + 2) / (float64(stat.Impressions) + 2)
|
||||||
|
}
|
||||||
|
if strings.Contains(id, "continue") {
|
||||||
|
score += 1000
|
||||||
|
reason = "Continue Watching"
|
||||||
|
}
|
||||||
|
if now.Weekday() == time.Friday && now.Hour() >= 18 && (strings.Contains(id, "movie") || strings.Contains(id, "film")) {
|
||||||
|
score += 8
|
||||||
|
reason = "Friday night films"
|
||||||
|
}
|
||||||
|
if now.Weekday() == time.Sunday && now.Hour() < 18 && (strings.Contains(id, "easy") || strings.Contains(id, "comfort")) {
|
||||||
|
score += 7
|
||||||
|
reason = "Something easy for Sunday"
|
||||||
|
}
|
||||||
|
if now.Hour() >= 20 && strings.Contains(id, "episode") {
|
||||||
|
score += 6
|
||||||
|
reason = "One episode before bed"
|
||||||
|
}
|
||||||
|
if strings.Contains(id, "for-you") || strings.Contains(id, "recommend") {
|
||||||
|
score += 3
|
||||||
|
if reason == "" {
|
||||||
|
reason = "New for you"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(id, "latest") || strings.Contains(id, "recent") {
|
||||||
|
score += 2
|
||||||
|
if reason == "" {
|
||||||
|
reason = "Recently added"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranked = append(ranked, scored{row: row, score: score, reason: reason, position: position})
|
||||||
|
}
|
||||||
|
sort.SliceStable(ranked, func(i, j int) bool {
|
||||||
|
if ranked[i].score != ranked[j].score {
|
||||||
|
return ranked[i].score > ranked[j].score
|
||||||
|
}
|
||||||
|
return ranked[i].position < ranked[j].position
|
||||||
|
})
|
||||||
|
out := make([]recommend.Row, 0, len(ranked))
|
||||||
|
relevance := make([]homeRowRelevance, 0, len(ranked))
|
||||||
|
for _, item := range ranked {
|
||||||
|
out = append(out, item.row)
|
||||||
|
relevance = append(relevance, homeRowRelevance{RowID: item.row.ID, Score: item.score, Reason: item.reason})
|
||||||
|
}
|
||||||
|
return out, relevance
|
||||||
|
}
|
||||||
|
|
||||||
// preparedHomeForYouRows promotes the specific abandoned-show shelf as well as the
|
// preparedHomeForYouRows promotes the specific abandoned-show shelf as well as the
|
||||||
// time-aware general picks. Other For You shelves remain in the dedicated destination.
|
// time-aware general picks. Other For You shelves remain in the dedicated destination.
|
||||||
func preparedHomeForYouRows(
|
func preparedHomeForYouRows(
|
||||||
|
|||||||
@@ -6,13 +6,29 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/config"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleRemoteConfig serves one app-scoped, immutable-at-runtime document. It is public
|
// handleRemoteConfig serves one app-scoped, versioned document. It is public
|
||||||
// for the same reason the update verdict is public: a fresh install and a signed-out TV
|
// for the same reason the update verdict is public: a fresh install and a signed-out TV
|
||||||
// must be able to warm the next launch. No viewer or session data belongs in this answer.
|
// must be able to warm the next launch. No viewer or session data belongs in this answer.
|
||||||
func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
body, err := json.Marshal(s.cfg.RemoteConfig)
|
document := s.cfg.RemoteConfig
|
||||||
|
clientSchema := parseClientSchema(r)
|
||||||
|
components := parseCapabilities(r.Header.Get("X-Memby-Components"))
|
||||||
|
if clientSchema > 0 && clientSchema < document.SchemaVersion {
|
||||||
|
document.SchemaVersion = clientSchema
|
||||||
|
}
|
||||||
|
policy := s.currentFeaturePolicy(r.Context())
|
||||||
|
applyGlobalConfiguration(&document, policy)
|
||||||
|
negotiateRemoteSections(&document, components)
|
||||||
|
if s.log != nil {
|
||||||
|
s.loggerFor(r.Context()).Info("remote configuration delivered", "client_version", r.Header.Get("X-Memby-Version"), "schema", clientSchema, "delivered_schema", document.SchemaVersion, "components", components, "config_version", document.ConfigVersion)
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(document)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Config is validated during start-up, so this is defensive rather than an expected
|
// Config is validated during start-up, so this is defensive rather than an expected
|
||||||
// operational failure.
|
// operational failure.
|
||||||
@@ -23,7 +39,9 @@ func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
etag := `"rc-` + hex.EncodeToString(digest[:12]) + `"`
|
etag := `"rc-` + hex.EncodeToString(digest[:12]) + `"`
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("ETag", etag)
|
w.Header().Set("ETag", etag)
|
||||||
w.Header().Set("X-Memby-Config-Version", configVersionHeader(s.cfg.RemoteConfig.ConfigVersion))
|
w.Header().Set("X-Memby-Config-Version", configVersionHeader(document.ConfigVersion))
|
||||||
|
w.Header().Set("X-Memby-Delivered-Schema", strconv.Itoa(document.SchemaVersion))
|
||||||
|
w.Header().Set("X-Memby-Delivered-Components", strings.Join(components, ","))
|
||||||
if r.Header.Get("If-None-Match") == etag {
|
if r.Header.Get("If-None-Match") == etag {
|
||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
return
|
return
|
||||||
@@ -31,6 +49,113 @@ func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeRaw(w, http.StatusOK, body)
|
writeRaw(w, http.StatusOK, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseClientSchema(r *http.Request) int {
|
||||||
|
value, _ := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Memby-Config-Schema")))
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func negotiateRemoteSections(document *config.RemoteConfig, components []string) {
|
||||||
|
if len(components) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
supported := make(map[string]bool, len(components))
|
||||||
|
for _, component := range components {
|
||||||
|
supported[component] = true
|
||||||
|
}
|
||||||
|
for _, sections := range []*[]config.RemoteSectionDefinition{&document.Home.SectionDefinitions, &document.Movies.SectionDefinitions, &document.TV.SectionDefinitions} {
|
||||||
|
for index := range *sections {
|
||||||
|
section := &(*sections)[index]
|
||||||
|
if supported[section.Component] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if section.Component == "landscapeMediaCardV2" && supported["mediaRow"] {
|
||||||
|
section.Component = "mediaRow"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
section.Enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyGlobalConfiguration is intentionally small and typed. The public startup
|
||||||
|
// document has no viewer identity, so user/device assignments are resolved by the
|
||||||
|
// authenticated feature endpoint rather than leaking into this cacheable response.
|
||||||
|
func applyGlobalConfiguration(document *config.RemoteConfig, policy store.FeaturePolicy) {
|
||||||
|
for key, raw := range policy.Values {
|
||||||
|
var value any
|
||||||
|
if json.Unmarshal(raw, &value) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "forYou.enabled":
|
||||||
|
if v, ok := value.(bool); ok {
|
||||||
|
document.ForYou.Enabled = v
|
||||||
|
document.Features.Flags["forYou.enabled"] = v
|
||||||
|
document.Features.Flags["for_you"] = v
|
||||||
|
}
|
||||||
|
case "continueWatching.enabled":
|
||||||
|
if v, ok := value.(bool); ok {
|
||||||
|
document.ContinueWatching.Enabled = v
|
||||||
|
document.Features.Flags["continueWatching.enabled"] = v
|
||||||
|
document.Features.Flags["continue_watching"] = v
|
||||||
|
}
|
||||||
|
case "continueWatching.showNextUp":
|
||||||
|
if v, ok := value.(bool); ok {
|
||||||
|
document.ContinueWatching.IncludeNextUp = v
|
||||||
|
}
|
||||||
|
case "continueWatching.progressColour":
|
||||||
|
if v, ok := value.(string); ok {
|
||||||
|
document.ContinueWatching.ProgressColour = v
|
||||||
|
}
|
||||||
|
case "home.heroRefreshSeconds":
|
||||||
|
if v, ok := value.(float64); ok {
|
||||||
|
document.Home.HeroRefreshSeconds = int(v)
|
||||||
|
}
|
||||||
|
case "home.maxItemsPerRow":
|
||||||
|
if v, ok := value.(float64); ok {
|
||||||
|
document.Home.MaxItemsPerRow = int(v)
|
||||||
|
}
|
||||||
|
case "home.sectionDefinitions":
|
||||||
|
if err := json.Unmarshal(raw, &document.Home.SectionDefinitions); err == nil {
|
||||||
|
document.Home.Sections = sectionIDs(document.Home.SectionDefinitions)
|
||||||
|
}
|
||||||
|
case "movies.sectionDefinitions":
|
||||||
|
if err := json.Unmarshal(raw, &document.Movies.SectionDefinitions); err == nil {
|
||||||
|
document.Movies.Sections = sectionIDs(document.Movies.SectionDefinitions)
|
||||||
|
}
|
||||||
|
case "tv.sectionDefinitions":
|
||||||
|
if err := json.Unmarshal(raw, &document.TV.SectionDefinitions); err == nil {
|
||||||
|
document.TV.Sections = sectionIDs(document.TV.SectionDefinitions)
|
||||||
|
}
|
||||||
|
case "hero.enabled":
|
||||||
|
if v, ok := value.(bool); ok {
|
||||||
|
document.Features.Flags["hero.enabled"] = v
|
||||||
|
}
|
||||||
|
case "branding.markUrl":
|
||||||
|
if v, ok := value.(string); ok {
|
||||||
|
document.Branding.MarkURL = v
|
||||||
|
}
|
||||||
|
case "branding.markVersion":
|
||||||
|
if v, ok := value.(string); ok {
|
||||||
|
document.Branding.MarkVersion = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := value.(bool); ok {
|
||||||
|
document.Features.Flags[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionIDs(definitions []config.RemoteSectionDefinition) []string {
|
||||||
|
ids := make([]string, 0, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
if definition.Enabled {
|
||||||
|
ids = append(ids, definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func configVersionHeader(version int64) string {
|
func configVersionHeader(version int64) string {
|
||||||
return strconv.FormatInt(version, 10)
|
return strconv.FormatInt(version, 10)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,14 @@ func Load() (Config, error) {
|
|||||||
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
|
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
|
||||||
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
|
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
|
||||||
}
|
}
|
||||||
|
// Integration capabilities are server facts, not secrets. Publish only whether each
|
||||||
|
// service is configured; API keys and URLs remain gateway-only. An operator can still
|
||||||
|
// disable the corresponding feature flag in the document without exposing credentials.
|
||||||
|
c.RemoteConfig.Integrations = RemoteIntegrations{
|
||||||
|
Tracearr: c.TracearrURL != "" && c.TracearrAPIKey != "",
|
||||||
|
Sonarr: c.SonarrURL != "" && c.SonarrAPIKey != "",
|
||||||
|
Radarr: c.RadarrURL != "" && c.RadarrAPIKey != "",
|
||||||
|
}
|
||||||
if c.AnalyticsRetention < 30*24*time.Hour {
|
if c.AnalyticsRetention < 30*24*time.Hour {
|
||||||
c.AnalyticsRetention = 30 * 24 * time.Hour
|
c.AnalyticsRetention = 30 * 24 * time.Hour
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RemoteConfig is the deliberately small, presentation-only document offered to TVs.
|
// RemoteConfig is the versioned, app-scoped control-plane document offered to TVs. It
|
||||||
// It must never contain authentication, playback or navigation-routing decisions: an
|
// contains behaviour switches and ordering, but never credentials or viewer state. An
|
||||||
// unavailable document is required to be indistinguishable from an ordinary offline
|
// unavailable document is safe because the APK carries equivalent bundled defaults.
|
||||||
// launch apart from its wording and safe presentation choices.
|
|
||||||
type RemoteConfig struct {
|
type RemoteConfig struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
ConfigVersion int64 `json:"configVersion"`
|
ConfigVersion int64 `json:"configVersion"`
|
||||||
@@ -20,6 +19,17 @@ type RemoteConfig struct {
|
|||||||
Copy RemoteConfigCopy `json:"copy"`
|
Copy RemoteConfigCopy `json:"copy"`
|
||||||
Features RemoteConfigFeatures `json:"features"`
|
Features RemoteConfigFeatures `json:"features"`
|
||||||
Presentation RemoteConfigPresentation `json:"presentation"`
|
Presentation RemoteConfigPresentation `json:"presentation"`
|
||||||
|
Home RemoteHomeConfig `json:"home"`
|
||||||
|
Movies RemotePageConfig `json:"movies"`
|
||||||
|
TV RemotePageConfig `json:"tv"`
|
||||||
|
ContinueWatching RemoteContinueWatching `json:"continueWatching"`
|
||||||
|
ForYou RemoteForYouConfig `json:"forYou"`
|
||||||
|
Recommendations RemoteRecommendations `json:"recommendations"`
|
||||||
|
Search RemoteSearchConfig `json:"search"`
|
||||||
|
UI RemoteUIConfig `json:"ui"`
|
||||||
|
Experimental map[string]bool `json:"experimental,omitempty"`
|
||||||
|
Integrations RemoteIntegrations `json:"integrations"`
|
||||||
|
Branding RemoteBranding `json:"branding"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoteConfigCopy struct {
|
type RemoteConfigCopy struct {
|
||||||
@@ -33,6 +43,7 @@ type RemoteConfigNavigationCopy struct {
|
|||||||
Search string `json:"search"`
|
Search string `json:"search"`
|
||||||
Movies string `json:"movies"`
|
Movies string `json:"movies"`
|
||||||
TVShows string `json:"tvShows"`
|
TVShows string `json:"tvShows"`
|
||||||
|
Genres string `json:"genres"`
|
||||||
TVCalendar string `json:"tvCalendar"`
|
TVCalendar string `json:"tvCalendar"`
|
||||||
Favourites string `json:"favourites"`
|
Favourites string `json:"favourites"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
@@ -40,7 +51,8 @@ type RemoteConfigNavigationCopy struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RemoteConfigFeatures struct {
|
type RemoteConfigFeatures struct {
|
||||||
ShowNavigationVersion bool `json:"showNavigationVersion"`
|
ShowNavigationVersion bool `json:"showNavigationVersion"`
|
||||||
|
Flags map[string]bool `json:"flags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoteConfigPresentation struct {
|
type RemoteConfigPresentation struct {
|
||||||
@@ -48,6 +60,81 @@ type RemoteConfigPresentation struct {
|
|||||||
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
|
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The remainder of the document is deliberately declarative. A new server-side row or
|
||||||
|
// option can be added to these lists without making the client understand it: older clients
|
||||||
|
// filter unknown ids and retain their bundled ordering for anything they do not know.
|
||||||
|
type RemoteHomeConfig struct {
|
||||||
|
Sections []string `json:"sections"`
|
||||||
|
SectionDefinitions []RemoteSectionDefinition `json:"sectionDefinitions,omitempty"`
|
||||||
|
ShowForYou bool `json:"showForYou"`
|
||||||
|
ShowSeasonal bool `json:"showSeasonal"`
|
||||||
|
HeroRefreshSeconds int `json:"heroRefreshSeconds"`
|
||||||
|
MaxItemsPerRow int `json:"maxItemsPerRow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemotePageConfig struct {
|
||||||
|
Sections []string `json:"sections"`
|
||||||
|
SectionDefinitions []RemoteSectionDefinition `json:"sectionDefinitions,omitempty"`
|
||||||
|
ShowGenres bool `json:"showGenres"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteSectionDefinition is the stable composition contract. Clients render only
|
||||||
|
// known component types and ignore definitions introduced by newer gateways.
|
||||||
|
type RemoteSectionDefinition struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
DataSource string `json:"dataSource"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
MaxItems int `json:"maxItems,omitempty"`
|
||||||
|
Destination string `json:"destination,omitempty"`
|
||||||
|
Settings map[string]any `json:"settings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteContinueWatching struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IncludeNextUp bool `json:"includeNextUp"`
|
||||||
|
MaxItems int `json:"maxItems"`
|
||||||
|
ProgressColour string `json:"progressColour"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteForYouConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MaxRows int `json:"maxRows"`
|
||||||
|
RefreshHours int `json:"refreshHours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteRecommendations struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Sections []string `json:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteSearchConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
GenresEnabled bool `json:"genresEnabled"`
|
||||||
|
MaxResults int `json:"maxResults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteUIConfig struct {
|
||||||
|
ArtworkStyle string `json:"artworkStyle"`
|
||||||
|
CardDensity string `json:"cardDensity"`
|
||||||
|
ShowWatchedBadges bool `json:"showWatchedBadges"`
|
||||||
|
ShowMediaTypeIcons bool `json:"showMediaTypeIcons"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteIntegrations struct {
|
||||||
|
Tracearr bool `json:"tracearr"`
|
||||||
|
Sonarr bool `json:"sonarr"`
|
||||||
|
Radarr bool `json:"radarr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteBranding struct {
|
||||||
|
MarkURL string `json:"markUrl,omitempty"`
|
||||||
|
MarkVersion string `json:"markVersion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultRemoteConfig mirrors the APK's bundled values. Serving it is still useful: it
|
// DefaultRemoteConfig mirrors the APK's bundled values. Serving it is still useful: it
|
||||||
// establishes the schema and ETag contract before an operator chooses an override.
|
// establishes the schema and ETag contract before an operator chooses an override.
|
||||||
func DefaultRemoteConfig() RemoteConfig {
|
func DefaultRemoteConfig() RemoteConfig {
|
||||||
@@ -58,15 +145,53 @@ func DefaultRemoteConfig() RemoteConfig {
|
|||||||
Tagline: "Matt’s Android TV client",
|
Tagline: "Matt’s Android TV client",
|
||||||
Navigation: RemoteConfigNavigationCopy{
|
Navigation: RemoteConfigNavigationCopy{
|
||||||
Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies",
|
Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies",
|
||||||
TVShows: "TV Shows", TVCalendar: "TV Calendar", Favourites: "Favourites",
|
TVShows: "TV Shows", Genres: "Genres", TVCalendar: "TV Calendar", Favourites: "Favourites",
|
||||||
User: "User", Settings: "Settings",
|
User: "User", Settings: "Settings",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Features: RemoteConfigFeatures{ShowNavigationVersion: true},
|
Features: RemoteConfigFeatures{
|
||||||
|
ShowNavigationVersion: true,
|
||||||
|
Flags: map[string]bool{
|
||||||
|
"continue_watching": true, "for_you": true, "recommendations": true,
|
||||||
|
"genre_browser": false, "tv_calendar": true, "tracearr": false,
|
||||||
|
"sonarr": false, "radarr": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
Presentation: RemoteConfigPresentation{
|
Presentation: RemoteConfigPresentation{
|
||||||
NavigationRailExpandedWidthDp: 184,
|
NavigationRailExpandedWidthDp: 184,
|
||||||
NavigationContentShiftDp: 112,
|
NavigationContentShiftDp: 112,
|
||||||
},
|
},
|
||||||
|
Home: RemoteHomeConfig{
|
||||||
|
Sections: []string{"continue", "for-you", "favorites", "latest-movies"},
|
||||||
|
SectionDefinitions: defaultHomeSections(),
|
||||||
|
ShowForYou: true, ShowSeasonal: true, HeroRefreshSeconds: 60, MaxItemsPerRow: 20,
|
||||||
|
},
|
||||||
|
Movies: RemotePageConfig{Sections: []string{"genres", "library"}, SectionDefinitions: defaultPageSections("movies"), ShowGenres: false},
|
||||||
|
TV: RemotePageConfig{Sections: []string{"genres", "library"}, SectionDefinitions: defaultPageSections("tv"), ShowGenres: false},
|
||||||
|
ContinueWatching: RemoteContinueWatching{Enabled: true, IncludeNextUp: true, MaxItems: 20, ProgressColour: "emby"},
|
||||||
|
ForYou: RemoteForYouConfig{Enabled: true, MaxRows: 3, RefreshHours: 24},
|
||||||
|
Recommendations: RemoteRecommendations{Enabled: true, Sections: []string{"for-you", "because-you-watched"}},
|
||||||
|
Search: RemoteSearchConfig{Enabled: true, GenresEnabled: false, MaxResults: 50},
|
||||||
|
UI: RemoteUIConfig{ArtworkStyle: "automatic", CardDensity: "standard", ShowWatchedBadges: true, ShowMediaTypeIcons: false},
|
||||||
|
Experimental: map[string]bool{},
|
||||||
|
Integrations: RemoteIntegrations{},
|
||||||
|
Branding: RemoteBranding{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultHomeSections() []RemoteSectionDefinition {
|
||||||
|
return []RemoteSectionDefinition{
|
||||||
|
{ID: "continue", Type: "continueWatching", Title: "Continue Watching", Enabled: true, Position: 10, DataSource: "emby.resume", Component: "mediaRow", MaxItems: 20, Destination: "home"},
|
||||||
|
{ID: "for-you", Type: "forYou", Title: "For You", Enabled: true, Position: 20, DataSource: "gateway.recommendations", Component: "mediaRow", MaxItems: 20, Destination: "for-you"},
|
||||||
|
{ID: "favorites", Type: "favorites", Title: "Favourites", Enabled: true, Position: 30, DataSource: "emby.favourites", Component: "mediaRow", MaxItems: 20, Destination: "home"},
|
||||||
|
{ID: "latest-movies", Type: "latest", Title: "Recently Added", Enabled: true, Position: 40, DataSource: "emby.latest", Component: "mediaRow", MaxItems: 20, Destination: "movies"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPageSections(destination string) []RemoteSectionDefinition {
|
||||||
|
return []RemoteSectionDefinition{
|
||||||
|
{ID: "genres", Type: "genres", Title: "Genres", Enabled: true, Position: 10, DataSource: "emby.genres", Component: "genreBrowser", MaxItems: 20, Destination: destination},
|
||||||
|
{ID: "library", Type: "library", Title: "Library", Enabled: true, Position: 20, DataSource: "emby.library", Component: "mediaGrid", MaxItems: 20, Destination: destination},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +199,9 @@ func loadRemoteConfig(raw string) (RemoteConfig, error) {
|
|||||||
if strings.TrimSpace(raw) == "" {
|
if strings.TrimSpace(raw) == "" {
|
||||||
return DefaultRemoteConfig(), nil
|
return DefaultRemoteConfig(), nil
|
||||||
}
|
}
|
||||||
var document RemoteConfig
|
// Start from defaults so a document published before a newly added section remains
|
||||||
|
// valid and receives the same safe behaviour as a bundled client.
|
||||||
|
document := DefaultRemoteConfig()
|
||||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&document); err != nil {
|
if err := decoder.Decode(&document); err != nil {
|
||||||
@@ -115,6 +242,7 @@ func validateRemoteConfig(document RemoteConfig) error {
|
|||||||
document.Copy.Navigation.Search,
|
document.Copy.Navigation.Search,
|
||||||
document.Copy.Navigation.Movies,
|
document.Copy.Navigation.Movies,
|
||||||
document.Copy.Navigation.TVShows,
|
document.Copy.Navigation.TVShows,
|
||||||
|
document.Copy.Navigation.Genres,
|
||||||
document.Copy.Navigation.TVCalendar,
|
document.Copy.Navigation.TVCalendar,
|
||||||
document.Copy.Navigation.Favourites,
|
document.Copy.Navigation.Favourites,
|
||||||
document.Copy.Navigation.User,
|
document.Copy.Navigation.User,
|
||||||
@@ -134,6 +262,62 @@ func validateRemoteConfig(document RemoteConfig) error {
|
|||||||
if shift < 80 || shift > 160 || shift >= width {
|
if shift < 80 || shift > 160 || shift >= width {
|
||||||
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
|
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
|
||||||
}
|
}
|
||||||
|
if err := validateRemoteConfigSections(document); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRemoteConfigSections(document RemoteConfig) error {
|
||||||
|
if len(document.Home.Sections) > 32 || len(document.Movies.Sections) > 32 || len(document.TV.Sections) > 32 || len(document.Recommendations.Sections) > 32 {
|
||||||
|
return fmt.Errorf("section ordering contains too many entries")
|
||||||
|
}
|
||||||
|
for _, sections := range [][]string{document.Home.Sections, document.Movies.Sections, document.TV.Sections, document.Recommendations.Sections} {
|
||||||
|
for _, section := range sections {
|
||||||
|
section = strings.TrimSpace(section)
|
||||||
|
if section == "" || len(section) > 64 || strings.ContainsAny(section, "\r\n\t") {
|
||||||
|
return fmt.Errorf("section ids must be between 1 and 64 characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range document.Features.Flags {
|
||||||
|
if strings.TrimSpace(key) == "" || len(key) > 64 {
|
||||||
|
return fmt.Errorf("feature flag ids must be between 1 and 64 characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range document.Experimental {
|
||||||
|
if strings.TrimSpace(key) == "" || len(key) > 64 {
|
||||||
|
return fmt.Errorf("experimental flag ids must be between 1 and 64 characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if document.ContinueWatching.MaxItems < 1 || document.ContinueWatching.MaxItems > 100 {
|
||||||
|
return fmt.Errorf("continueWatching.maxItems must be between 1 and 100")
|
||||||
|
}
|
||||||
|
if document.ForYou.MaxRows < 0 || document.ForYou.MaxRows > 20 || document.ForYou.RefreshHours < 1 || document.ForYou.RefreshHours > 168 {
|
||||||
|
return fmt.Errorf("forYou limits are unsafe")
|
||||||
|
}
|
||||||
|
if document.Search.MaxResults < 1 || document.Search.MaxResults > 200 {
|
||||||
|
return fmt.Errorf("search.maxResults must be between 1 and 200")
|
||||||
|
}
|
||||||
|
if document.UI.ArtworkStyle != "automatic" && document.UI.ArtworkStyle != "poster" && document.UI.ArtworkStyle != "backdrop" {
|
||||||
|
return fmt.Errorf("ui.artworkStyle is invalid")
|
||||||
|
}
|
||||||
|
if document.UI.CardDensity != "standard" && document.UI.CardDensity != "compact" && document.UI.CardDensity != "large" {
|
||||||
|
return fmt.Errorf("ui.cardDensity is invalid")
|
||||||
|
}
|
||||||
|
for _, definitions := range [][]RemoteSectionDefinition{document.Home.SectionDefinitions, document.Movies.SectionDefinitions, document.TV.SectionDefinitions} {
|
||||||
|
if len(definitions) > 64 {
|
||||||
|
return fmt.Errorf("remote configuration has too many section definitions")
|
||||||
|
}
|
||||||
|
for _, section := range definitions {
|
||||||
|
if strings.TrimSpace(section.ID) == "" || strings.TrimSpace(section.Type) == "" || strings.TrimSpace(section.Component) == "" {
|
||||||
|
return fmt.Errorf("remote configuration section definitions require id, type and component")
|
||||||
|
}
|
||||||
|
if section.Position < 0 || section.MaxItems < 0 || section.MaxItems > 500 {
|
||||||
|
return fmt.Errorf("remote configuration section has an invalid position or item limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
|
|||||||
if document.Copy.Navigation.Favourites != "Favourites" {
|
if document.Copy.Navigation.Favourites != "Favourites" {
|
||||||
t.Fatalf("favourites label = %q", document.Copy.Navigation.Favourites)
|
t.Fatalf("favourites label = %q", document.Copy.Navigation.Favourites)
|
||||||
}
|
}
|
||||||
|
if !document.ContinueWatching.IncludeNextUp || len(document.Home.Sections) == 0 {
|
||||||
|
t.Fatalf("central defaults are incomplete: %+v", document)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteConfigDefaultsMissingNewSectionsForOlderDocuments(t *testing.T) {
|
||||||
|
raw := `{"schemaVersion":1,"configVersion":3,"copy":{"navigation":{"home":"Home","forYou":"For You","search":"Search","movies":"Movies","tvShows":"TV Shows","tvCalendar":"TV Calendar","favourites":"Favourites","user":"User","settings":"Settings"},"tagline":"Matt’s Android TV client"},"features":{"showNavigationVersion":true},"presentation":{"navigationRailExpandedWidthDp":184,"navigationContentShiftDp":112}}`
|
||||||
|
document, err := loadRemoteConfig(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !document.ContinueWatching.Enabled || document.Search.MaxResults != 50 {
|
||||||
|
t.Fatalf("new fields did not receive defaults: %+v", document)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {
|
func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {
|
||||||
|
|||||||
@@ -400,30 +400,52 @@ func (s *Store) SetMDBListSettings(ctx context.Context, settings MDBListSettings
|
|||||||
const FeaturePolicyKey = "feature_policy"
|
const FeaturePolicyKey = "feature_policy"
|
||||||
|
|
||||||
type FeaturePolicySnapshot struct {
|
type FeaturePolicySnapshot struct {
|
||||||
Overrides map[string]bool `json:"overrides"`
|
Overrides map[string]bool `json:"overrides"`
|
||||||
SafeMode bool `json:"safeMode"`
|
Values map[string]json.RawMessage `json:"values,omitempty"`
|
||||||
Revision int64 `json:"revision"`
|
UserValues map[string]map[string]json.RawMessage `json:"userValues,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues,omitempty"`
|
||||||
|
Experimental map[string]json.RawMessage `json:"experimental,omitempty"`
|
||||||
|
SafeMode bool `json:"safeMode"`
|
||||||
|
Revision int64 `json:"revision"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeaturePolicy struct {
|
type FeaturePolicy struct {
|
||||||
Overrides map[string]bool `json:"overrides"`
|
Overrides map[string]bool `json:"overrides"`
|
||||||
SafeMode bool `json:"safeMode"`
|
// Values is the central typed configuration store. Raw JSON keeps the store
|
||||||
Revision int64 `json:"revision"`
|
// forward-compatible while the API validates each catalogue entry's type.
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
Values map[string]json.RawMessage `json:"values,omitempty"`
|
||||||
Previous *FeaturePolicySnapshot `json:"previous,omitempty"`
|
UserValues map[string]map[string]json.RawMessage `json:"userValues,omitempty"`
|
||||||
|
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues,omitempty"`
|
||||||
|
Experimental map[string]json.RawMessage `json:"experimental,omitempty"`
|
||||||
|
SafeMode bool `json:"safeMode"`
|
||||||
|
Revision int64 `json:"revision"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
Previous *FeaturePolicySnapshot `json:"previous,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrFeaturePolicyConflict = errors.New("store: feature policy revision conflict")
|
var ErrFeaturePolicyConflict = errors.New("store: feature policy revision conflict")
|
||||||
|
|
||||||
func DefaultFeaturePolicy() FeaturePolicy {
|
func DefaultFeaturePolicy() FeaturePolicy {
|
||||||
return FeaturePolicy{Overrides: map[string]bool{}}
|
return FeaturePolicy{Overrides: map[string]bool{}, Values: map[string]json.RawMessage{}, UserValues: map[string]map[string]json.RawMessage{}, DeviceValues: map[string]map[string]json.RawMessage{}, Experimental: map[string]json.RawMessage{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeFeaturePolicy(policy FeaturePolicy) FeaturePolicy {
|
func normalizeFeaturePolicy(policy FeaturePolicy) FeaturePolicy {
|
||||||
if policy.Overrides == nil {
|
if policy.Overrides == nil {
|
||||||
policy.Overrides = map[string]bool{}
|
policy.Overrides = map[string]bool{}
|
||||||
}
|
}
|
||||||
|
if policy.Values == nil {
|
||||||
|
policy.Values = map[string]json.RawMessage{}
|
||||||
|
}
|
||||||
|
if policy.UserValues == nil {
|
||||||
|
policy.UserValues = map[string]map[string]json.RawMessage{}
|
||||||
|
}
|
||||||
|
if policy.DeviceValues == nil {
|
||||||
|
policy.DeviceValues = map[string]map[string]json.RawMessage{}
|
||||||
|
}
|
||||||
|
if policy.Experimental == nil {
|
||||||
|
policy.Experimental = map[string]json.RawMessage{}
|
||||||
|
}
|
||||||
return policy
|
return policy
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +497,7 @@ func (s *Store) SetFeaturePolicy(
|
|||||||
next.UpdatedAt = time.Now().UTC()
|
next.UpdatedAt = time.Now().UTC()
|
||||||
next.Previous = &FeaturePolicySnapshot{
|
next.Previous = &FeaturePolicySnapshot{
|
||||||
Overrides: current.Overrides, SafeMode: current.SafeMode,
|
Overrides: current.Overrides, SafeMode: current.SafeMode,
|
||||||
|
Values: current.Values, UserValues: current.UserValues, DeviceValues: current.DeviceValues, Experimental: current.Experimental,
|
||||||
Revision: current.Revision, UpdatedAt: current.UpdatedAt,
|
Revision: current.Revision, UpdatedAt: current.UpdatedAt,
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(next)
|
raw, err := json.Marshal(next)
|
||||||
|
|||||||
Reference in New Issue
Block a user