This commit is contained in:
ponzischeme89
2026-08-22 12:38:26 +12:00
parent f982d391b9
commit b3e3f62c54
26 changed files with 1279 additions and 108 deletions
Binary file not shown.
+15
View File
@@ -211,6 +211,21 @@ export interface FeaturePolicy {
updatedAt?: string;
canRollback: boolean;
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 {
+3 -3
View File
@@ -219,9 +219,9 @@ export const nav: NavGroup[] = [
{
id: 'features',
path: '/admin/features',
label: 'Features',
title: 'Features',
intro: 'Roll out, stop and recover optional behaviour with no app release.',
label: 'Client configuration',
title: 'Client configuration',
intro: 'Control everything the thin TV client renders, without releasing an APK.',
icon: 'sliders',
},
{
+79 -2
View File
@@ -36,6 +36,7 @@ export function FeaturesPage() {
const policy = status?.features;
const features = policy?.features ?? [];
const configuration = policy?.configuration ?? [];
const clients = status?.clients ?? [];
const revision = policy?.revision ?? 0;
@@ -64,12 +65,16 @@ export function FeaturesPage() {
),
[key]: enabled,
});
const valuesFor = (key: string, value: unknown) => ({
...Object.fromEntries(configuration.map((item) => [item.key, item.value])),
[key]: value,
});
return (
<>
<PageHead
title="Features"
intro="Roll out, stop and recover optional behaviour with no app release."
title="Client configuration"
intro="One server-owned control plane for what Memby renders: flags, values, page composition and contextual discovery."
/>
<Banner message={error} />
@@ -77,6 +82,18 @@ export function FeaturesPage() {
<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
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."
@@ -121,6 +138,66 @@ export function FeaturesPage() {
/>
</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">
{features.length === 0 ? (
<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.
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.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(
"X-Memby-Capabilities",
(
@@ -1,34 +1,45 @@
package com.ponzischeme89.memby.data.remoteconfig
import android.content.Context
import android.os.Build
import android.graphics.BitmapFactory
import android.util.Log
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit
import java.io.File
import java.net.URI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
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.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.Request
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
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 PREFERENCES_NAME = "memby_remote_config"
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
* only replaces the durable last-known-good document; it deliberately does not publish a
* Flow or mutable state that could repaint wording under somebody's focus.
* The cached value is chosen before any Activity is created. A successful refresh replaces
* the durable last-known-good document and publishes the new immutable snapshot atomically.
*/
@Serializable
data class MembyRemoteConfig(
@@ -39,7 +50,22 @@ data class MembyRemoteConfig(
val copy: RemoteCopy,
val features: RemoteFeatures,
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
get() = NavigationRemoteConfig(
labels = copy.navigation,
@@ -75,13 +101,94 @@ data class NavigationLabels(
@Serializable
data class RemoteFeatures(
val showNavigationVersion: Boolean,
val showNavigationVersion: Boolean = true,
val flags: Map<String, Boolean> = emptyMap(),
)
@Serializable
data class RemotePresentation(
val navigationRailExpandedWidthDp: Int,
val navigationContentShiftDp: Int,
val navigationRailExpandedWidthDp: Int = 184,
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. */
@@ -119,6 +226,17 @@ object BundledRemoteConfig {
navigationRailExpandedWidthDp = 184,
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.
*
* 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
* [active]. A killed write therefore leaves either the old complete value or the new one.
* path commits only after decoding and validation, on an IO dispatcher, and publishes the
* 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) {
private val appContext = context.applicationContext
private val preferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE,
@@ -149,7 +269,12 @@ class RemoteConfigManager(context: Context) {
?.let(::decodeCachedRemoteConfig)
?.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)
@@ -158,18 +283,26 @@ class RemoteConfigManager(context: Context) {
val gatewayUrl = ServerConfig.gatewayUrl ?: return
val cached = cachedAtStart
scope.launch {
refreshBranding(_active.value.branding)
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()
.url(gatewayUrl.trimEnd('/') + "/v1/config")
.header("Accept", "application/json")
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.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()
val client = HttpStack.base.newBuilder()
// This request is speculative and affects only a later process. Give it less
@@ -181,34 +314,73 @@ class RemoteConfigManager(context: Context) {
.build()
runCatching {
client.newCall(request).execute().use { response ->
if (response.code == 304) return
if (!response.isSuccessful) return
val body = response.body ?: return
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return
if (response.code == 304) return cachedEtag
if (!response.isSuccessful) return cachedEtag
val body = response.body ?: return cachedEtag
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return cachedEtag
val source = body.source()
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 document = decodeRemoteConfig(raw) ?: return
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return
val heldVersion = cached?.document?.configVersion ?: 0
if (document.configVersion <= heldVersion) return
val document = decodeRemoteConfig(raw) ?: return cachedEtag
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return cachedEtag
// Config versions are monotonic hints, not the change detector: a gateway
// 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 {
it.isNotBlank() && it.length <= 128 && !it.contains('\n') && !it.contains('\r')
} ?: return
} ?: return cachedEtag
val encoded = remoteConfigJson.encodeToString(CachedRemoteConfig(etag, document))
if (!preferences.edit().putString(DOCUMENT_KEY, encoded).commit()) {
Log.w("MembyRemoteConfig", "Could not persist remote configuration")
}
if (_active.value != document) _active.value = document
refreshBranding(document.branding)
return etag
}
}.onFailure {
if (it is CancellationException) throw it
// A failed speculative refresh is ordinary offline behaviour. The active
// bundled/cached document remains complete and no screen needs to know.
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 {
remoteConfigJson.decodeFromString<MembyRemoteConfig>(raw)
}.getOrNull()
@@ -252,6 +424,25 @@ internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: Strin
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
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
}
@@ -66,6 +66,9 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
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.theme.MembyAccent
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
* 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 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 mapped = state.rows
.foldNextUpIntoContinue()
.filter { row ->
when (row.kind) {
"continue", "nextup" -> "continue" in enabledSections
"continue", "nextup" ->
continueEnabled && "continue" in enabledSections
"favorites" -> "favorites" 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
// 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.
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 ->
@@ -464,7 +482,9 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
} else {
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()
} else {
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)
}
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,
* 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,
state: HomeUiState,
settings: Settings,
remoteConfig: MembyRemoteConfig = BundledRemoteConfig.value,
): List<HomeBrowseRow> {
val continueRow = HomeBrowseRow(
id = "continue",
title = "Continue Watching",
items = state.continueWatching,
items = continueWatchingItems(state.continueWatching, remoteConfig.continueWatching),
kind = MediaRowKind.CONTINUE,
loading = HomeSection.CONTINUE in state.loading,
emptyMessage = "Nothing in progress",
@@ -593,7 +645,7 @@ internal fun homeRowsFor(
// 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.
BrowseDestination.HOME -> if (state.rows.isNotEmpty()) {
serverHomeRows(state, settings)
serverHomeRows(state, settings, remoteConfig)
// Genre/studio shelves have dedicated Movies and TV destinations. Home
// keeps personalised, current and new-release rows so each screen has a
// 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
// studio affinity. Broad latest/library dumps belong on Home, not here.
.filter { row ->
@@ -643,7 +695,7 @@ internal fun homeRowsFor(
title = "Favourite Shows",
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
// engine. Keep their server order: it is the user's affinity ranking.
.filter { row ->
@@ -671,13 +723,44 @@ internal fun homeRowsFor(
} else {
deduplicated
}
return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
applyHomeRowPreferences(populated, settings)
val sectionOrder = when (destination) {
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 {
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
* in Continue Watching cannot immediately reappear as a genre recommendation, and live
@@ -195,6 +195,7 @@ internal fun HomeScreen(
remoteConfig: MembyRemoteConfig,
) {
val repo = ServiceLocator.repository
val remoteMarkPath by ServiceLocator.remoteConfig.markPathFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val factory = remember(repo) { HomeViewModelFactory(repo) }
@@ -748,7 +749,7 @@ internal fun HomeScreen(
val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) {
forYouBrowseRows(forYouState)
} else {
homeRowsFor(selectedDestination, homeContent, settings)
homeRowsFor(selectedDestination, homeContent, settings, remoteConfig)
}
applyWatchedVisibility(destinationRows, settings.hideWatchedMovies)
}
@@ -926,6 +927,7 @@ internal fun HomeScreen(
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
config = remoteConfig.navigation,
markPath = remoteMarkPath,
selected = if (userSwitcherVisible) {
BrowseDestination.PROFILES
} else {
@@ -2339,6 +2341,7 @@ internal fun HomeScreen(
) {
TvNavigationRail(
config = remoteConfig.navigation,
markPath = remoteMarkPath,
selected = if (userSwitcherVisible) {
BrowseDestination.PROFILES
} else {
@@ -3,6 +3,8 @@ package com.ponzischeme89.memby.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.performance.PerformanceMonitor
import com.ponzischeme89.memby.ui.player.PrerollPreloader
@@ -13,10 +15,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
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 {
val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle()
MembyTheme {
AppRoot(
remoteConfig = remoteConfig,
@@ -66,9 +66,12 @@ data class ResumableMediaCardModel(
val primaryUrl: String? = null,
val played: Boolean = false,
val favourite: Boolean = false,
val isNextUp: Boolean = false,
) {
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
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(
@@ -77,6 +80,8 @@ internal fun BaseItem.toResumableMediaCardModel(
): ResumableMediaCardModel {
val episodeName = name.trim().takeIf(String::isNotEmpty)
val seriesTitle = seriesName?.trim().orEmpty()
val playbackPosition = userData?.playbackPositionTicks
val played = userData?.played == true
return ResumableMediaCardModel(
id = id,
title = if (isEpisode) {
@@ -87,12 +92,16 @@ internal fun BaseItem.toResumableMediaCardModel(
episodeName = episodeName.takeIf { isEpisode && it != seriesTitle },
seasonNumber = parentIndexNumber.takeIf { isEpisode },
episodeNumber = indexNumber.takeIf { isEpisode },
playbackPositionTicks = userData?.playbackPositionTicks,
playbackPositionTicks = playbackPosition,
runtimeTicks = runTimeTicks,
backdropUrl = backdropUrl,
primaryUrl = primaryUrl,
played = userData?.played == true,
played = played,
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,13 +177,22 @@ fun ResumableMediaCard(
delay(MINUTE_MILLIS - now % MINUTE_MILLIS)
}
}
val finishAt = remember(nowEpochMillis, model.playbackPositionTicks, model.runtimeTicks) {
val finishAt = remember(
nowEpochMillis,
model.isNextUp,
model.playbackPositionTicks,
model.runtimeTicks,
) {
if (model.isNextUp) {
null
} else {
expectedFinishEpochMillis(
nowEpochMillis = nowEpochMillis,
positionTicks = model.playbackPositionTicks,
runtimeTicks = model.runtimeTicks,
)
}
}
val endsAt = remember(finishAt, context) {
finishAt?.let {
val localTime = DateFormat.getTimeFormat(context)
@@ -184,11 +202,12 @@ fun ResumableMediaCard(
"Ends at: $localTime"
}
}
val description = remember(model, endsAt) {
val progressLabel = model.nextUpLabel ?: endsAt
val description = remember(model, progressLabel) {
listOfNotNull(
model.title.takeIf(String::isNotBlank),
model.episodeLabel,
endsAt,
progressLabel,
model.progress.takeIf { it > 0f }?.let { "${(it * 100).toInt()} percent watched" },
).joinToString(", ")
}
@@ -256,7 +275,7 @@ fun ResumableMediaCard(
modifier = Modifier.size(30.dp),
)
}
if (model.progress > 0f) {
if (model.showProgressTrack) {
Box(
Modifier
.align(Alignment.BottomCenter)
@@ -264,6 +283,7 @@ fun ResumableMediaCard(
.height(5.dp)
.background(Color.Black.copy(alpha = 0.65f)),
) {
if (model.progress > 0f) {
Box(
Modifier
.fillMaxWidth(model.progress)
@@ -272,6 +292,7 @@ fun ResumableMediaCard(
)
}
}
}
if (model.played || model.favourite) {
Row(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
@@ -312,7 +333,7 @@ fun ResumableMediaCard(
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
endsAt?.let { label ->
progressLabel?.let { label ->
Text(
text = label,
color = MembyQuietText,
@@ -11,6 +11,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.Image
import coil.compose.AsyncImage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -168,6 +169,7 @@ fun TvNavigationRail(
* beside screens that have nowhere to put a menu.
*/
onUserLongPressed: (() -> Unit)? = null,
markPath: String? = null,
) {
var railHasFocus by remember { mutableStateOf(false) }
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
// app's icon, the same one the launcher on the television shows, so a
// seasonal palette repainting it would make it a different mark.
Image(
painter = painterResource(R.drawable.memby_mark),
if (markPath != null) AsyncImage(
model = markPath,
contentDescription = "Memby",
modifier = Modifier
.size(30.dp)
@@ -254,6 +256,12 @@ fun TvNavigationRail(
alpha = logoAlpha.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) {
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)
}
@Test
fun olderDocumentReceivesSafeDefaultsForNewSections() {
val document = decodeRemoteConfig(validDocument)!!
assertEquals(true, document.continueWatching.enabled)
assertEquals(50, document.search.maxResults)
assertEquals("automatic", document.ui.artworkStyle)
}
@Test
fun incompatibleSchemaAndAppVersionsAreRejected() {
val document = decodeRemoteConfig(validDocument)!!
@@ -1,8 +1,11 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ResumableMediaCardTest {
@@ -60,6 +63,48 @@ class ResumableMediaCardTest {
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 {
const val MILLIS_PER_SECOND = 1_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.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.data.remoteconfig.RemoteContinueWatching
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -16,6 +17,31 @@ import org.junit.Test
* the server invented, and surviving a cold start from cache.
*/
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
fun `profile row preferences hide pin and order server rows`() {
val serverRows = listOf(
+205 -5
View File
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -42,6 +43,41 @@ type featureDefinition struct {
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{
{
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
@@ -197,6 +233,60 @@ type featureResponse struct {
UpdatedAt any `json:"updatedAt,omitempty"`
CanRollback bool `json:"canRollback"`
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) {
@@ -213,6 +303,20 @@ func evaluateFeature(policy store.FeaturePolicy, definition featureDefinition, p
if override, ok := policy.Overrides[definition.Key]; ok {
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 {
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 {
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))
for _, definition := range featureCatalogue {
evaluated := evaluateFeature(policy, definition, protocol)
@@ -260,11 +368,17 @@ func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]
}
features = append(features, evaluated)
}
return featureResponse{
response := featureResponse{
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
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 {
@@ -275,9 +389,9 @@ func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string)
return result
}
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, _ store.Session) {
writeJSON(w, http.StatusOK, featurePayload(
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), clientCapabilities(r),
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, session store.Session) {
writeJSON(w, http.StatusOK, featurePayloadForSession(
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), &session, clientCapabilities(r),
))
}
@@ -285,6 +399,10 @@ type featurePolicyRequest struct {
Action string `json:"action"`
ExpectedRevision int64 `json:"expectedRevision"`
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) {
@@ -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")
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) {
case "save":
for key, enabled := range req.Overrides {
@@ -310,11 +428,17 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
}
next.Overrides[key] = enabled
}
if err := validateConfigurationValues(next); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
case "safe-mode":
next.Overrides = current.Overrides
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
next.SafeMode = true
case "leave-safe-mode":
next.Overrides = current.Overrides
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
next.SafeMode = false
case "reset":
next.SafeMode = false
@@ -324,6 +448,7 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
return
}
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
default:
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))
}
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 {
seen := map[string]bool{}
values := []string{}
+44 -3
View File
@@ -159,6 +159,7 @@ type heroCandidate struct {
// when none did, which is a different thing from a score of zero.
Rating float64
Rated bool
Source string
}
// heroRecency decays linearly across the window.
@@ -189,6 +190,16 @@ func heroScore(candidate heroCandidate, now time.Time) float64 {
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
// 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
// evidence behind it says nothing rather than inventing a reason.
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
fresh := heroRecency(candidate.ReleasedAt, now) > 0
switch {
@@ -857,7 +884,7 @@ func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroC
}
byID[fact.ID] = heroCandidate{
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))
@@ -949,6 +976,20 @@ func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]hero
seen[fact.ID] = true
facts[fact.ID] = fact
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{
ID: fact.ID,
Name: fact.Name,
@@ -956,7 +997,7 @@ func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]hero
Item: raw,
ReleasedAt: fact.Premiere,
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,
ReleasedAt: premiere.AiredAt,
Rating: rating,
Rated: rated,
Rated: rated, Source: "continue_world",
})
}
return candidates
+17 -1
View File
@@ -17,9 +17,18 @@ import (
type activeHeroResponse struct {
Placement string `json:"placement"`
Source string `json:"source"`
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
// supplies only a placement; schedules, priorities, pins and ranking remain gateway data.
func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -83,6 +92,9 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
location := s.heroLocation()
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
for index := range scheduled {
scheduled[index].Source = "time_sensitive"
}
var candidates []heroCandidate
if placement == store.HeroPlacementTVShows {
@@ -109,9 +121,13 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
return response, nil
}
items := make([]json.RawMessage, 0, len(ranked))
metadata := make([]activeHeroCandidate, 0, len(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})
s.loggerFor(ctx).Debug("section hero resolved", "placement", placement, "source", source, "items", len(items))
return response, nil
+79 -1
View File
@@ -50,6 +50,7 @@ type homeResponse struct {
// decided here, so a new row (a recommendation strip, a seasonal collection) ships
// without touching the TV app. The client renders whatever arrives.
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
// 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.
}
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.
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
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 = s.filterRecommendationPermissions(ctx, sess, out.Rows)
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()
rank := timing.Start(ctx, timing.StageRank)
@@ -528,6 +537,75 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
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
// time-aware general picks. Other For You shelves remain in the dedicated destination.
func preparedHomeForYouRows(
+128 -3
View File
@@ -6,13 +6,29 @@ import (
"encoding/json"
"net/http"
"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
// 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) {
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 {
// Config is validated during start-up, so this is defensive rather than an expected
// operational failure.
@@ -23,7 +39,9 @@ func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
etag := `"rc-` + hex.EncodeToString(digest[:12]) + `"`
w.Header().Set("Cache-Control", "no-cache")
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 {
w.WriteHeader(http.StatusNotModified)
return
@@ -31,6 +49,113 @@ func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
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 {
return strconv.FormatInt(version, 10)
}
+8
View File
@@ -288,6 +288,14 @@ func Load() (Config, error) {
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
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 {
c.AnalyticsRetention = 30 * 24 * time.Hour
}
+191 -7
View File
@@ -8,10 +8,9 @@ import (
"strings"
)
// RemoteConfig is the deliberately small, presentation-only document offered to TVs.
// It must never contain authentication, playback or navigation-routing decisions: an
// unavailable document is required to be indistinguishable from an ordinary offline
// launch apart from its wording and safe presentation choices.
// RemoteConfig is the versioned, app-scoped control-plane document offered to TVs. It
// contains behaviour switches and ordering, but never credentials or viewer state. An
// unavailable document is safe because the APK carries equivalent bundled defaults.
type RemoteConfig struct {
SchemaVersion int `json:"schemaVersion"`
ConfigVersion int64 `json:"configVersion"`
@@ -20,6 +19,17 @@ type RemoteConfig struct {
Copy RemoteConfigCopy `json:"copy"`
Features RemoteConfigFeatures `json:"features"`
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 {
@@ -33,6 +43,7 @@ type RemoteConfigNavigationCopy struct {
Search string `json:"search"`
Movies string `json:"movies"`
TVShows string `json:"tvShows"`
Genres string `json:"genres"`
TVCalendar string `json:"tvCalendar"`
Favourites string `json:"favourites"`
User string `json:"user"`
@@ -41,6 +52,7 @@ type RemoteConfigNavigationCopy struct {
type RemoteConfigFeatures struct {
ShowNavigationVersion bool `json:"showNavigationVersion"`
Flags map[string]bool `json:"flags,omitempty"`
}
type RemoteConfigPresentation struct {
@@ -48,6 +60,81 @@ type RemoteConfigPresentation struct {
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
// establishes the schema and ETag contract before an operator chooses an override.
func DefaultRemoteConfig() RemoteConfig {
@@ -58,15 +145,53 @@ func DefaultRemoteConfig() RemoteConfig {
Tagline: "Matts Android TV client",
Navigation: RemoteConfigNavigationCopy{
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",
},
},
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{
NavigationRailExpandedWidthDp: 184,
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) == "" {
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.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil {
@@ -115,6 +242,7 @@ func validateRemoteConfig(document RemoteConfig) error {
document.Copy.Navigation.Search,
document.Copy.Navigation.Movies,
document.Copy.Navigation.TVShows,
document.Copy.Navigation.Genres,
document.Copy.Navigation.TVCalendar,
document.Copy.Navigation.Favourites,
document.Copy.Navigation.User,
@@ -134,6 +262,62 @@ func validateRemoteConfig(document RemoteConfig) error {
if shift < 80 || shift > 160 || shift >= 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
}
@@ -16,6 +16,20 @@ func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
if document.Copy.Navigation.Favourites != "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":"Matts 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) {
+24 -1
View File
@@ -401,6 +401,10 @@ const FeaturePolicyKey = "feature_policy"
type FeaturePolicySnapshot struct {
Overrides map[string]bool `json:"overrides"`
Values map[string]json.RawMessage `json:"values,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"`
@@ -408,6 +412,12 @@ type FeaturePolicySnapshot struct {
type FeaturePolicy struct {
Overrides map[string]bool `json:"overrides"`
// Values is the central typed configuration store. Raw JSON keeps the store
// forward-compatible while the API validates each catalogue entry's type.
Values map[string]json.RawMessage `json:"values,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"`
@@ -417,13 +427,25 @@ type FeaturePolicy struct {
var ErrFeaturePolicyConflict = errors.New("store: feature policy revision conflict")
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 {
if policy.Overrides == nil {
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
}
@@ -475,6 +497,7 @@ func (s *Store) SetFeaturePolicy(
next.UpdatedAt = time.Now().UTC()
next.Previous = &FeaturePolicySnapshot{
Overrides: current.Overrides, SafeMode: current.SafeMode,
Values: current.Values, UserValues: current.UserValues, DeviceValues: current.DeviceValues, Experimental: current.Experimental,
Revision: current.Revision, UpdatedAt: current.UpdatedAt,
}
raw, err := json.Marshal(next)