0.2.55 - Remote config/Request fixes

This commit is contained in:
ponzischeme89
2026-08-12 09:57:56 +12:00
parent 4b31946635
commit 9777eb0952
35 changed files with 1086 additions and 78 deletions
+9 -3
View File
@@ -18,6 +18,8 @@ MEMBY_PORT=32768
# playback progress reports and search terms.
MEMBY_LOG_LEVEL=INFO
MEMBY_LOG_BUFFER_CAPACITY=5000
# The Compose default is a named-volume-backed JSONL archive restored by the admin log.
MEMBY_LOG_HISTORY_PATH=/data/logs/events.jsonl
# console (default) is one aligned line per event, timestamp first and no `time=` key.
# logfmt restores slog's own key=value line; json is for a log collector.
MEMBY_LOG_FORMAT=console
@@ -34,6 +36,10 @@ MEMBY_RECOMMEND_TTL=24h
# MEMBY_RECOMMENDATION_WEIGHTS={"ExplorationRate":0.08,"MinimumEvidence":2,"ImpressionPenalty":0.12}
MEMBY_RECOMMENDATION_WEIGHTS=
# Optional complete Remote Config document. Leave blank for bundled-equivalent defaults.
# Every change must increase configVersion; see server/REMOTE_CONFIG.md.
MEMBY_REMOTE_CONFIG_JSON=
# Maximum number of distinct Memby TVs one Emby user may keep signed in.
# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the
@@ -89,11 +95,11 @@ MEMBY_RADARR_ALERT_WINDOW=3h
# redeployment. The second provider, OpenSubtitles, is configured entirely on that page —
# it needs only an API key, and the credentials live in the database rather than in this
# file.
MEMBY_BAZARR_URL=http://10.0.0.2:6767
MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
#MEMBY_BAZARR_URL=http://10.0.0.2:6767
#MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
# How long Bazarr's movie/series/episode listings are cached. They exist only to turn an
# Emby item into the Radarr/Sonarr id Bazarr keys on.
MEMBY_BAZARR_TTL=5m
#MEMBY_BAZARR_TTL=5m
# A manual search queries live subtitle providers and is legitimately slow, so it gets a
# longer timeout than the rest of the upstreams. Too short reads to a viewer as "no
# subtitles found" rather than as a timeout.
+8 -2
View File
@@ -591,6 +591,8 @@ pieces make that true and each is easy to undo:
(`component`, `user`, `device`, `client`) first so it can be read as a column, the
constant `version` and the `error` last. `MEMBY_LOG_FORMAT` switches to `logfmt` or
`json`; the ring buffer the admin page reads is fed the same records in every format.
That ring is restored from a bounded JSONL archive in the persistent `memby-logs`
volume, so a deployment replaces the process without erasing the operator's history.
- `internal/api/logcontext.go` carries a **`*requestIdentity` in the request context**.
`withLogging` creates it from the route and the client headers; `authed` fills in the
viewer and television once the session resolves; both the handler's own events
@@ -714,12 +716,16 @@ Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks
`resumePositionMs`).
**A detail page is warmed while its card is focused, in two waves.** By the time somebody
presses a card, the item record, its "why you might enjoy it" and its playable URL have all
been fetched — and now so have its episode list and its trailer, which were the two things
presses a card, the item record and its "why you might enjoy it" have been fetched — and now
so have its episode list and its trailer, which were the two things
the page still opened cold. That mattered most on Continue Watching, where every card is an
episode and pressing one opened a page with no season scroller and no episode list until the
network answered. Things to preserve:
- **Playback itself is never warmed on focus.** Emby's PlaybackInfo negotiation creates a
playback session, so asking for it while somebody merely browses pollutes server history
with titles they never played. Stream resolution begins only after a Play action.
- **The two waves have deliberately different delays.** The metadata warm follows the D-pad
closely at `FOCUS_METADATA_DEBOUNCE_MS` (140 ms) because it decides what the panel beside
the row says; `warmDetailPage` waits `DETAIL_PREFETCH_DELAY_MS` (450 ms) because an
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
import com.ponzischeme89.memby.update.RequiredUpdateGuard
/**
@@ -25,6 +26,8 @@ object ServiceLocator {
private set
lateinit var maintenance: MaintenanceMonitor
private set
lateinit var remoteConfig: RemoteConfigManager
private set
/**
* Held rather than discarded because it is a long-lived collector, not a service
@@ -54,6 +57,9 @@ object ServiceLocator {
// Only hands the probe an application context; it does no work until the first
// request or playback negotiation asks what this television's receiver accepts.
installAudioCapabilityProbe(context)
// One tiny synchronous preference read selects an immutable process snapshot.
// Network revalidation is delayed and can only affect the next process.
remoteConfig = RemoteConfigManager(context.applicationContext)
settings = SettingsStore(context.applicationContext)
// Started before the repository, so a refusal answering the very first request a
// television makes has somewhere to be recorded.
@@ -69,5 +75,6 @@ object ServiceLocator {
// because the surfaces that obey it — the launcher, the player's Compose islands,
// the screensaver's DreamService — are separate roots with no common owner but this.
themeSync = ThemeSync(repository, settings, maintenance.theme)
remoteConfig.refreshLater()
}
}
@@ -872,6 +872,7 @@ class EmbyRepository(private val settings: SettingsStore) {
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
mediaType = candidate.mediaType,
foreignId = candidate.foreignId,
title = candidate.title,
),
).title
}
@@ -446,6 +446,7 @@ data class GatewayRequestLookup(
data class GatewayMediaRequest(
val mediaType: String,
val foreignId: Int,
val title: String,
)
@Serializable
@@ -0,0 +1,264 @@
package com.ponzischeme89.memby.data.remoteconfig
import android.content.Context
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Request
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
private const val REMOTE_CONFIG_START_DELAY_MS = 5_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.
*
* 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.
*/
@Serializable
data class MembyRemoteConfig(
val schemaVersion: Int,
val configVersion: Long,
val minimumAppVersion: String = "",
val maximumAppVersion: String = "",
val copy: RemoteCopy,
val features: RemoteFeatures,
val presentation: RemotePresentation,
) {
val navigation: NavigationRemoteConfig
get() = NavigationRemoteConfig(
labels = copy.navigation,
tagline = copy.tagline,
showVersion = features.showNavigationVersion,
expandedWidthDp = presentation.navigationRailExpandedWidthDp,
contentShiftDp = presentation.navigationContentShiftDp,
)
}
@Serializable
data class RemoteCopy(
val navigation: NavigationLabels,
val tagline: String,
)
@Serializable
data class NavigationLabels(
val home: String,
val forYou: String,
val search: String,
val movies: String,
val tvShows: String,
val tvCalendar: String,
val favourites: String,
val user: String,
val settings: String,
)
@Serializable
data class RemoteFeatures(
val showNavigationVersion: Boolean,
)
@Serializable
data class RemotePresentation(
val navigationRailExpandedWidthDp: Int,
val navigationContentShiftDp: Int,
)
/** The single UI-facing projection, so Compose never performs stringly typed lookups. */
data class NavigationRemoteConfig(
val labels: NavigationLabels,
val tagline: String,
val showVersion: Boolean,
val expandedWidthDp: Int,
val contentShiftDp: Int,
)
object BundledRemoteConfig {
val value = MembyRemoteConfig(
schemaVersion = REMOTE_CONFIG_SCHEMA_VERSION,
// Bundled data is the floor, not a remotely issued revision. Any valid server
// document therefore wins on a later process start.
configVersion = 0,
copy = RemoteCopy(
tagline = "Matts Android TV client",
navigation = NavigationLabels(
home = "Home",
forYou = "For You",
search = "Search",
movies = "Movies",
tvShows = "TV Shows",
tvCalendar = "TV Calendar",
favourites = "Favourites",
user = "User",
settings = "Settings",
),
),
features = RemoteFeatures(showNavigationVersion = true),
presentation = RemotePresentation(
navigationRailExpandedWidthDp = 184,
navigationContentShiftDp = 112,
),
)
}
@Serializable
private data class CachedRemoteConfig(
val etag: String,
val document: MembyRemoteConfig,
)
private val remoteConfigJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
/**
* 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.
*/
class RemoteConfigManager(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE,
)
private val cachedAtStart = preferences.getString(DOCUMENT_KEY, null)
?.let(::decodeCachedRemoteConfig)
?.takeIf { validateRemoteConfig(it.document, BuildConfig.VERSION_NAME) == null }
val active: MembyRemoteConfig = cachedAtStart?.document ?: BundledRemoteConfig.value
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Schedules a tiny revalidation after the launcher's critical start-up work. */
fun refreshLater() {
val gatewayUrl = ServerConfig.gatewayUrl ?: return
val cached = cachedAtStart
scope.launch {
delay(REMOTE_CONFIG_START_DELAY_MS)
fetch(gatewayUrl, cached)
}
}
private fun fetch(gatewayUrl: String, cached: CachedRemoteConfig?) {
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) } }
.build()
val client = HttpStack.base.newBuilder()
// This request is speculative and affects only a later process. Give it less
// patience than any screen-facing call and never retry it in this session.
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(2, TimeUnit.SECONDS)
.callTimeout(3, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.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
val source = body.source()
source.request(MAX_REMOTE_CONFIG_BYTES + 1)
if (source.buffer.size > MAX_REMOTE_CONFIG_BYTES) return
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 etag = response.header("ETag")?.takeIf {
it.isNotBlank() && it.length <= 128 && !it.contains('\n') && !it.contains('\r')
} ?: return
val encoded = remoteConfigJson.encodeToString(CachedRemoteConfig(etag, document))
if (!preferences.edit().putString(DOCUMENT_KEY, encoded).commit()) {
Log.w("MembyRemoteConfig", "Could not persist remote configuration")
}
}
}.onFailure {
// 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)
}
}
}
internal fun decodeRemoteConfig(raw: String): MembyRemoteConfig? = runCatching {
remoteConfigJson.decodeFromString<MembyRemoteConfig>(raw)
}.getOrNull()
private fun decodeCachedRemoteConfig(raw: String): CachedRemoteConfig? = runCatching {
remoteConfigJson.decodeFromString<CachedRemoteConfig>(raw)
}.getOrNull()
/** Returns null only when the whole document is safe to activate. */
internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: String): String? {
if (document.schemaVersion != REMOTE_CONFIG_SCHEMA_VERSION) return "unsupported schema"
if (document.configVersion < 1) return "invalid configuration version"
val current = semanticVersion(appVersion) ?: return "invalid app version"
document.minimumAppVersion.takeIf(String::isNotBlank)?.let { minimum ->
val parsed = semanticVersion(minimum) ?: return "invalid minimum app version"
if (current < parsed) return "app is older than the configuration minimum"
}
document.maximumAppVersion.takeIf(String::isNotBlank)?.let { maximum ->
val parsed = semanticVersion(maximum) ?: return "invalid maximum app version"
if (current > parsed) return "app is newer than the configuration maximum"
}
val copy = document.copy
val labels = listOf(
copy.tagline,
copy.navigation.home,
copy.navigation.forYou,
copy.navigation.search,
copy.navigation.movies,
copy.navigation.tvShows,
copy.navigation.tvCalendar,
copy.navigation.favourites,
copy.navigation.user,
copy.navigation.settings,
)
if (labels.any { it.isBlank() || it != it.trim() || it.length > 64 || it.any(Char::isISOControl) }) {
return "invalid copy"
}
val width = document.presentation.navigationRailExpandedWidthDp
val shift = document.presentation.navigationContentShiftDp
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
return "unsafe presentation values"
}
return null
}
private data class SemanticVersion(val major: Int, val minor: Int, val patch: Int) :
Comparable<SemanticVersion> {
override fun compareTo(other: SemanticVersion): Int =
compareValuesBy(this, other, SemanticVersion::major, SemanticVersion::minor, SemanticVersion::patch)
}
private fun semanticVersion(raw: String): SemanticVersion? {
val parts = raw.split('.')
if (parts.size != 3) return null
val numbers = parts.map { it.toIntOrNull() ?: return null }
if (numbers.any { it < 0 }) return null
return SemanticVersion(numbers[0], numbers[1], numbers[2])
}
@@ -140,6 +140,9 @@ import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -236,6 +239,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
@Composable
fun TvNavigationRail(
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
selected: BrowseDestination,
focusDestination: BrowseDestination = selected,
expanded: Boolean,
@@ -269,7 +273,7 @@ fun TvNavigationRail(
}
}
val railWidth = animateDpAsState(
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
targetValue = if (expanded) config.expandedWidthDp.dp else TvRailCollapsedWidth,
animationSpec = tween(160),
label = "navigation-rail-width",
)
@@ -339,7 +343,7 @@ fun TvNavigationRail(
maxLines = 1,
)
Text(
"Matts Android TV client",
config.tagline,
color = QuietText,
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
@@ -364,7 +368,7 @@ fun TvNavigationRail(
label = if (destination == BrowseDestination.PROFILES) {
profileDestinationLabel(activeUsername)
} else {
destination.label
configuredNavigationLabel(destination, config.labels)
},
selected = destination == selected,
expanded = expanded,
@@ -389,6 +393,7 @@ fun TvNavigationRail(
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.weight(1f))
if (config.showVersion) {
Text(
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
color = QuietText,
@@ -399,6 +404,22 @@ fun TvNavigationRail(
)
}
}
}
}
private fun configuredNavigationLabel(
destination: BrowseDestination,
labels: NavigationLabels,
): String = when (destination) {
BrowseDestination.HOME -> labels.home
BrowseDestination.FOR_YOU -> labels.forYou
BrowseDestination.SEARCH -> labels.search
BrowseDestination.MOVIES -> labels.movies
BrowseDestination.SHOWS -> labels.tvShows
BrowseDestination.CALENDAR -> labels.tvCalendar
BrowseDestination.FAVORITES -> labels.favourites
BrowseDestination.PROFILES -> labels.user
BrowseDestination.SETTINGS -> labels.settings
}
@Composable
@@ -389,14 +389,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// Resolution is tiny compared with video buffering and makes the later
// Play click a memory lookup. The repository single-flights requests,
// so focus and click can never duplicate the gateway call.
if (item.membyPlayable) {
// The row owns live UserData. Prefetching the metadata-cache copy used
// to turn a 5% resume point into zero after details had been visited.
launch { runCatching { repository.prefetchPlayable(focused) } }
}
// Do not negotiate playback on focus. Emby creates a playback session as
// part of PlaybackInfo, so warming a stream here made merely browsing a
// shelf appear in server history as something the viewer had played.
// Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later.
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
@@ -423,7 +418,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* The two requests a detail page still opened cold, warmed while the card is focused.
*
* Everything else the page needs is already in hand by the time it opens the item
* record, its "why you might enjoy it" and its playable URL are all warmed above but
* record and its "why you might enjoy it" are warmed above but
* the episode list and the trailer were not, so a series page opened with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page
* opened with its trailer button missing until the network answered. Continue Watching
@@ -131,6 +131,7 @@ import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.RecommendationPerson
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
@@ -246,8 +247,11 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
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 {
MembyTheme { AppRoot(onCloseSettings = ::finish) }
MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) }
}
PerformanceMonitor.start(this)
}
@@ -298,7 +302,7 @@ private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
@Composable
private fun AppRoot(onCloseSettings: () -> Unit) {
private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository
val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active
@@ -643,7 +647,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
}
key(loaded.userId, loaded.serverUrl) {
HomeScreen(settings = loaded)
HomeScreen(settings = loaded, remoteConfig = remoteConfig)
}
// Snow, bats or blossom for the few days a year a season is on, over the
// launcher and nowhere else. Not over playback — a film is the one thing
@@ -1887,6 +1891,7 @@ private fun ProfileTile(
@Composable
private fun HomeScreen(
settings: Settings,
remoteConfig: MembyRemoteConfig,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
@@ -2347,13 +2352,14 @@ private fun HomeScreen(
}
val contentShift by animateDpAsState(
targetValue = if (navigationExpanded) TvRailContentShift else 0.dp,
targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp,
animationSpec = tween(150),
label = "navigation-content-shift",
)
Box(Modifier.fillMaxSize().background(MembySurface)) {
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
config = remoteConfig.navigation,
selected = selectedDestination,
focusDestination = railFocusDestination,
expanded = navigationExpanded,
@@ -316,7 +316,7 @@ internal fun MyShowDetailsOverlay(
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
StatusLine("Sonarr", show.sonarrStatus)
StatusLine("Series", show.sonarrStatus)
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
StatusLine("Series status", show.lifecycle)
Row(
@@ -156,7 +156,7 @@ internal fun CalendarContent(
state.errorMessage.orEmpty(), "Try again", onRetry,
)
!state.calendar.available -> AgendaNotice(
"The TV calendar is not available. It needs the Memby gateway with Sonarr configured.",
"The TV calendar is not available. It needs the Memby gateway with a series service configured.",
)
week == null -> AgendaNotice("Nothing scheduled.")
else -> {
@@ -1029,7 +1029,7 @@ private fun RequestOptions(
Column(Modifier.weight(1f)) {
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(
"Search Radarr and Sonarr, then choose the exact movie or series you want added.",
"Search for movies and series, then choose the exact title you want added.",
color = Muted,
fontSize = 13.sp,
maxLines = 2,
@@ -0,0 +1,91 @@
package com.ponzischeme89.memby.data.remoteconfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class MembyRemoteConfigTest {
@Test
fun bundledDefaultsAreCompleteButNotARemoteRevision() {
val bundled = BundledRemoteConfig.value
assertEquals(0, bundled.configVersion)
assertEquals("Favourites", bundled.navigation.labels.favourites)
assertEquals(184, bundled.navigation.expandedWidthDp)
}
@Test
fun validCompatibleDocumentDecodes() {
val document = decodeRemoteConfig(validDocument)!!
assertNull(validateRemoteConfig(document, "0.2.54"))
assertEquals("Discover", document.navigation.labels.search)
assertEquals(false, document.navigation.showVersion)
}
@Test
fun incompatibleSchemaAndAppVersionsAreRejected() {
val document = decodeRemoteConfig(validDocument)!!
assertNotNull(validateRemoteConfig(document.copy(schemaVersion = 2), "0.2.54"))
assertNotNull(validateRemoteConfig(document, "0.2.53"))
assertNotNull(
validateRemoteConfig(
document.copy(minimumAppVersion = "", maximumAppVersion = "0.2.53"),
"0.2.54",
),
)
}
@Test
fun malformedCopyAndUnsafePresentationAreRejected() {
val document = decodeRemoteConfig(validDocument)!!
assertNotNull(
validateRemoteConfig(
document.copy(copy = document.copy.copy(tagline = "\nNot safe")),
"0.2.54",
),
)
assertNotNull(
validateRemoteConfig(
document.copy(
presentation = document.presentation.copy(
navigationRailExpandedWidthDp = 500,
),
),
"0.2.54",
),
)
}
private companion object {
val validDocument = """
{
"schemaVersion": 1,
"configVersion": 7,
"minimumAppVersion": "0.2.54",
"copy": {
"navigation": {
"home": "Home",
"forYou": "For You",
"search": "Discover",
"movies": "Films",
"tvShows": "TV Shows",
"tvCalendar": "TV Calendar",
"favourites": "Favourites",
"user": "User",
"settings": "Settings"
},
"tagline": "Your library, made personal"
},
"features": { "showNavigationVersion": false },
"presentation": {
"navigationRailExpandedWidthDp": 196,
"navigationContentShiftDp": 120
}
}
""".trimIndent()
}
}
+7
View File
@@ -15,6 +15,8 @@ services:
# health checks, maintenance polling and artwork requests.
MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}"
MEMBY_LOG_BUFFER_CAPACITY: "${MEMBY_LOG_BUFFER_CAPACITY:-5000}"
# Structured history is restored into the admin log after a deployment.
MEMBY_LOG_HISTORY_PATH: "${MEMBY_LOG_HISTORY_PATH:-/data/logs/events.jsonl}"
# console is one aligned, readable line per event; logfmt and json are for tools.
MEMBY_LOG_FORMAT: "${MEMBY_LOG_FORMAT:-console}"
GOMEMLIMIT: "${MEMBY_GOMEMLIMIT:-384MiB}"
@@ -29,6 +31,9 @@ services:
MEMBY_REDIS_URL: "redis://redis:6379/0"
MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}"
MEMBY_RECOMMEND_TTL: "${MEMBY_RECOMMEND_TTL:-24h}"
# Complete, schema-validated presentation document. Blank serves bundled-equivalent
# defaults; downloaded changes are activated by TVs on their next app process.
MEMBY_REMOTE_CONFIG_JSON: "${MEMBY_REMOTE_CONFIG_JSON:-}"
# Strict per-Emby-user TV allowance. Signing the same physical TV in again
# replaces its token and does not consume another slot.
# Unset disables /admin entirely — the library import, maintenance switch and
@@ -68,6 +73,7 @@ services:
mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}"
volumes:
- memby-releases:/data/releases
- memby-logs:/data/logs
depends_on:
postgres:
condition: service_healthy
@@ -110,3 +116,4 @@ services:
volumes:
memby-postgres:
memby-releases:
memby-logs:
+2 -1
View File
@@ -8,7 +8,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN mkdir -p /out/releases
RUN mkdir -p /out/releases /out/logs
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
@@ -18,6 +18,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/memby-server /app/memby-server
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
COPY --from=build --chown=nonroot:nonroot /out/logs /data/logs
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/memby-server"]
+7 -1
View File
@@ -70,6 +70,11 @@ same order: **who and where** first (`component`, `user`, `device`, `client`), t
the event is about, with `version` (the gateway build) and `error` last. `MEMBY_LOG_FORMAT`
switches the whole stream to `logfmt` (slog's own text) or `json` for a collector.
The same structured records are appended to `MEMBY_LOG_HISTORY_PATH` as JSONL and restored
into the admin log when the gateway starts. Compose mounts that path from the persistent
`memby-logs` volume, so replacing the container for a new version keeps the previous
history. The archive is compacted to the configured buffer capacity and remains bounded.
Every line from a request carries the viewer, the television, the app build and the
`component` — the part of the app the call came from, derived from the route, so it is
right even for an APK too old to report anything about itself.
@@ -608,7 +613,8 @@ can review and revoke signed-in TVs from the app's Settings screen.
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `MEMBY_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | |
| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests |
| `MEMBY_LOG_BUFFER_CAPACITY` | `5000` | Bounded in-memory admin event ring; `0` disables capture |
| `MEMBY_LOG_BUFFER_CAPACITY` | `5000` | Bounded persistent admin event history; `0` disables capture |
| `MEMBY_LOG_HISTORY_PATH` | `/data/logs/events.jsonl` | JSONL history restored after container replacement |
| `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` |
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container |
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
+48
View File
@@ -0,0 +1,48 @@
# Remote Config
Membys Remote Config is deliberately limited to copy, an optional presentation flag and
bounded navigation-rail measurements. It cannot control authentication, playback, routes or
any behaviour needed to reach and use the library.
Set `MEMBY_REMOTE_CONFIG_JSON` in the deployments `.env`, as one JSON line, then redeploy
the gateway. The value is one complete document; partial documents and unknown fields stop the gateway
at start-up instead of silently inventing a mixed configuration. Increase `configVersion`
for every change. A television will validate and store the new document in the background,
then activate it only when a new app process starts.
```json
{
"schemaVersion": 1,
"configVersion": 2,
"minimumAppVersion": "0.2.54",
"maximumAppVersion": "",
"copy": {
"navigation": {
"home": "Home",
"forYou": "For You",
"search": "Discover",
"movies": "Films",
"tvShows": "TV Shows",
"tvCalendar": "TV Calendar",
"favourites": "Favourites",
"user": "User",
"settings": "Settings"
},
"tagline": "Your library, made personal"
},
"features": {
"showNavigationVersion": false
},
"presentation": {
"navigationRailExpandedWidthDp": 196,
"navigationContentShiftDp": 120
}
}
```
The gateway sends a content-derived `ETag`; an unchanged client receives `304 Not Modified`.
The APK accepts schema 1 only, requires a strictly newer positive `configVersion`, checks its
own version against the optional inclusive bounds, limits every piece of copy to 64 characters
without control whitespace, and bounds the two measurements. A missing, slow, malformed or
incompatible response leaves the last-known-good document untouched. If no valid cache exists,
the complete APK defaults are used.
+12 -1
View File
@@ -49,7 +49,18 @@ func main() {
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000)
logFormat := logging.ParseFormat(os.Getenv("MEMBY_LOG_FORMAT"))
log, events := logging.NewBuffered(os.Stdout, logLevel, logCapacity, logFormat)
logHistoryPath := strings.TrimSpace(os.Getenv("MEMBY_LOG_HISTORY_PATH"))
if logHistoryPath == "" {
logHistoryPath = "/data/logs/events.jsonl"
}
log, events, err := logging.NewPersistentBuffered(
os.Stdout, logLevel, logCapacity, logFormat, logHistoryPath,
)
if err != nil {
os.Stderr.WriteString("open persistent log history: " + err.Error() + "\n")
os.Exit(1)
}
defer events.Close()
// Every line names the build that wrote it. A gateway is deployed from a working
// tree, often while a television is running an older app, so "which server said
// this" is a real question that a reader should never have to scroll for.
+2 -2
View File
@@ -53,8 +53,8 @@ Admin.onStatus((status) => {
const mdblist = status.mdblist || {};
const forYou = status.forYou || {};
$('overview-services').innerHTML =
row('Radarr', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Sonarr', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Movies', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Series', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('MDBList ratings', mdblist.enabled
? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok')
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) +
@@ -2,8 +2,8 @@
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="inbox" data-icon-tone="info">Where a request goes</h2>
<p class="card-note">A film is added to Radarr and a show to Sonarr, both unmonitored.
No download search starts on its own.</p>
<p class="card-note">A movie or series is monitored and searched for immediately.
The configured download service handles it from there.</p>
</div>
<span class="row tight" id="request-services"></span>
</div>
+2 -2
View File
@@ -2,9 +2,9 @@ const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
$('request-services').innerHTML =
ui.tag('Radarr ' + (status.radarrReady ? 'ready' : 'not configured'),
ui.tag('Movies ' + (status.radarrReady ? 'ready' : 'not configured'),
status.radarrReady ? 'ok' : 'bad') +
ui.tag('Sonarr ' + (status.sonarrReady ? 'ready' : 'not configured'),
ui.tag('Series ' + (status.sonarrReady ? 'ready' : 'not configured'),
status.sonarrReady ? 'ok' : 'bad');
const box = $('request-users');
+4
View File
@@ -205,6 +205,10 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady)
// Remote Config is app-scoped, contains presentation data only, and warms the next
// process. Keep it outside authentication and maintenance so offline/start-up fallback
// never depends on a session being available.
mux.HandleFunc("GET /v1/config", s.handleRemoteConfig)
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
// whether the server requires an update. A valid session enriches only its log context.
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
)
// handleRemoteConfig serves one app-scoped, immutable-at-runtime 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)
if err != nil {
// Config is validated during start-up, so this is defensive rather than an expected
// operational failure.
writeError(w, http.StatusInternalServerError, "remote configuration unavailable")
return
}
digest := sha256.Sum256(body)
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))
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeRaw(w, http.StatusOK, body)
}
func configVersionHeader(version int64) string {
return strconv.FormatInt(version, 10)
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T) {
server := &Server{cfg: config.Config{RemoteConfig: config.DefaultRemoteConfig()}}
first := httptest.NewRecorder()
server.handleRemoteConfig(first, httptest.NewRequest(http.MethodGet, "/v1/config", nil))
if first.Code != http.StatusOK {
t.Fatalf("status = %d", first.Code)
}
etag := first.Header().Get("ETag")
if etag == "" {
t.Fatal("missing ETag")
}
if got := first.Header().Get("X-Memby-Config-Version"); got != "1" {
t.Fatalf("version header = %q", got)
}
request := httptest.NewRequest(http.MethodGet, "/v1/config", nil)
request.Header.Set("If-None-Match", etag)
second := httptest.NewRecorder()
server.handleRemoteConfig(second, request)
if second.Code != http.StatusNotModified {
t.Fatalf("revalidation status = %d", second.Code)
}
}
+48 -10
View File
@@ -169,6 +169,7 @@ func sonarrCoverURL(images []sonarr.Image, kind string) string {
type requestPayload struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
Title string `json:"title"`
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -185,16 +186,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
writeError(w, http.StatusBadRequest, "foreignId is required")
return
}
req.Title = strings.TrimSpace(req.Title)
if titleRunes := []rune(req.Title); len(titleRunes) > 240 {
req.Title = string(titleRunes[:240])
}
switch req.MediaType {
case "movie":
if s.radarr == nil {
writeError(w, http.StatusServiceUnavailable, "Radarr is not configured")
s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured"))
writeError(w, http.StatusServiceUnavailable, "movie requests are not configured")
return
}
movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "Radarr lookup failed")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "movie lookup failed")
return
}
for _, movie := range movies {
@@ -205,26 +212,33 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
// Idempotent under a lost response: OkHttp may replay a repeatable POST after
// a connection reset. If the first request already added it, the retry is the
// same successful action rather than an error shown to the viewer.
req.Title = movie.Title
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
return
}
added, err := s.radarr.AddUnmonitored(r.Context(), movie)
req.Title = movie.Title
added, err := s.radarr.AddRequested(r.Context(), movie)
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "could not add movie to Radarr")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie")
return
}
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "movie", "title", added.Title)
req.Title = added.Title
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
case "series":
if s.sonarr == nil {
writeError(w, http.StatusServiceUnavailable, "Sonarr is not configured")
s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured"))
writeError(w, http.StatusServiceUnavailable, "series requests are not configured")
return
}
series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "Sonarr lookup failed")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "series lookup failed")
return
}
for _, show := range series {
@@ -232,25 +246,49 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
continue
}
if show.ID > 0 {
req.Title = show.Title
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
return
}
added, err := s.sonarr.AddUnmonitored(r.Context(), show)
req.Title = show.Title
added, err := s.sonarr.AddRequested(r.Context(), show)
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "could not add series to Sonarr")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that series")
return
}
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "series", "title", added.Title)
req.Title = added.Title
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
default:
s.logMediaRequest(r.Context(), req, "failed", errors.New("unsupported media type"))
writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`)
return
}
s.logMediaRequest(r.Context(), req, "failed", errors.New("title was not found"))
writeError(w, http.StatusNotFound, "title was not found")
}
func (s *Server) logMediaRequest(
ctx context.Context, req requestPayload, outcome string, err error,
) {
fields := []any{
"type", req.MediaType,
"title", clientLogValue(req.Title),
"foreign_id", req.ForeignID,
"outcome", outcome,
}
if err != nil {
fields = append(fields, "error", err)
s.loggerFor(ctx).Warn("media request failed", fields...)
return
}
s.loggerFor(ctx).Info("media request "+outcome, fields...)
}
func (s *Server) writeRequestUpstreamError(
ctx context.Context, w http.ResponseWriter, err error, message string,
) {
+27 -1
View File
@@ -1,6 +1,14 @@
package api
import "testing"
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
)
func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
if requestMatchScore("the office", "The Office") >=
@@ -11,3 +19,21 @@ func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
t.Fatal("exact title should rank ahead of a contained match")
}
}
func TestMediaRequestLogNamesTitleAndOutcome(t *testing.T) {
var output bytes.Buffer
server := &Server{log: serverlogging.New(&output, slog.LevelInfo)}
server.logMediaRequest(context.Background(), requestPayload{
MediaType: "series", ForeignID: 123, Title: "Severance",
}, "successful", nil)
line := output.String()
for _, want := range []string{
"media request successful", "type=series", "title=Severance",
"foreign_id=123", "outcome=successful",
} {
if !strings.Contains(line, want) {
t.Fatalf("log %q does not contain %q", line, want)
}
}
}
+8
View File
@@ -45,6 +45,9 @@ type Config struct {
RecommendTimeout time.Duration
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
RecommendationWeights string
// RemoteConfig is the complete, validated presentation document served to TVs.
// It is app-scoped and intentionally contains no account, playback or routing state.
RemoteConfig RemoteConfig
UpstreamTimeout time.Duration
@@ -138,6 +141,10 @@ type Config struct {
}
func Load() (Config, error) {
remoteConfig, err := loadRemoteConfig(os.Getenv("MEMBY_REMOTE_CONFIG_JSON"))
if err != nil {
return Config{}, err
}
c := Config{
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
@@ -156,6 +163,7 @@ func Load() (Config, error) {
RecommendationWeights: strings.TrimSpace(
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
),
RemoteConfig: remoteConfig,
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"encoding/json"
"fmt"
"io"
"strconv"
"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.
type RemoteConfig struct {
SchemaVersion int `json:"schemaVersion"`
ConfigVersion int64 `json:"configVersion"`
MinimumAppVersion string `json:"minimumAppVersion,omitempty"`
MaximumAppVersion string `json:"maximumAppVersion,omitempty"`
Copy RemoteConfigCopy `json:"copy"`
Features RemoteConfigFeatures `json:"features"`
Presentation RemoteConfigPresentation `json:"presentation"`
}
type RemoteConfigCopy struct {
Navigation RemoteConfigNavigationCopy `json:"navigation"`
Tagline string `json:"tagline"`
}
type RemoteConfigNavigationCopy struct {
Home string `json:"home"`
ForYou string `json:"forYou"`
Search string `json:"search"`
Movies string `json:"movies"`
TVShows string `json:"tvShows"`
TVCalendar string `json:"tvCalendar"`
Favourites string `json:"favourites"`
User string `json:"user"`
Settings string `json:"settings"`
}
type RemoteConfigFeatures struct {
ShowNavigationVersion bool `json:"showNavigationVersion"`
}
type RemoteConfigPresentation struct {
NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"`
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
}
// 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 {
return RemoteConfig{
SchemaVersion: 1,
ConfigVersion: 1,
Copy: RemoteConfigCopy{
Tagline: "Matts Android TV client",
Navigation: RemoteConfigNavigationCopy{
Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies",
TVShows: "TV Shows", TVCalendar: "TV Calendar", Favourites: "Favourites",
User: "User", Settings: "Settings",
},
},
Features: RemoteConfigFeatures{ShowNavigationVersion: true},
Presentation: RemoteConfigPresentation{
NavigationRailExpandedWidthDp: 184,
NavigationContentShiftDp: 112,
},
}
}
func loadRemoteConfig(raw string) (RemoteConfig, error) {
if strings.TrimSpace(raw) == "" {
return DefaultRemoteConfig(), nil
}
var document RemoteConfig
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil {
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON must contain exactly one document")
}
if err := validateRemoteConfig(document); err != nil {
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
}
return document, nil
}
func validateRemoteConfig(document RemoteConfig) error {
if document.SchemaVersion != 1 {
return fmt.Errorf("schemaVersion must be 1")
}
if document.ConfigVersion < 1 {
return fmt.Errorf("configVersion must be positive")
}
minimum, minimumSet, err := parseRemoteConfigVersion(document.MinimumAppVersion)
if err != nil {
return fmt.Errorf("minimumAppVersion must be a three-part version")
}
maximum, maximumSet, err := parseRemoteConfigVersion(document.MaximumAppVersion)
if err != nil {
return fmt.Errorf("maximumAppVersion must be a three-part version")
}
if minimumSet && maximumSet && compareRemoteConfigVersions(minimum, maximum) > 0 {
return fmt.Errorf("minimumAppVersion must not be newer than maximumAppVersion")
}
labels := []string{
document.Copy.Tagline,
document.Copy.Navigation.Home,
document.Copy.Navigation.ForYou,
document.Copy.Navigation.Search,
document.Copy.Navigation.Movies,
document.Copy.Navigation.TVShows,
document.Copy.Navigation.TVCalendar,
document.Copy.Navigation.Favourites,
document.Copy.Navigation.User,
document.Copy.Navigation.Settings,
}
for _, label := range labels {
trimmed := strings.TrimSpace(label)
if trimmed == "" || len([]rune(trimmed)) > 64 || strings.ContainsAny(trimmed, "\r\n\t") {
return fmt.Errorf("copy must be between 1 and 64 characters and contain no control whitespace")
}
}
width := document.Presentation.NavigationRailExpandedWidthDp
shift := document.Presentation.NavigationContentShiftDp
if width < 160 || width > 240 {
return fmt.Errorf("navigationRailExpandedWidthDp must be between 160 and 240")
}
if shift < 80 || shift > 160 || shift >= width {
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
}
return nil
}
func parseRemoteConfigVersion(raw string) ([3]int, bool, error) {
var version [3]int
if strings.TrimSpace(raw) == "" {
return version, false, nil
}
parts := strings.Split(raw, ".")
if len(parts) != len(version) {
return version, false, fmt.Errorf("invalid version")
}
for index, part := range parts {
value, err := strconv.Atoi(part)
if err != nil || value < 0 {
return version, false, fmt.Errorf("invalid version")
}
version[index] = value
}
return version, true, nil
}
func compareRemoteConfigVersions(left, right [3]int) int {
for index := range left {
if left[index] < right[index] {
return -1
}
if left[index] > right[index] {
return 1
}
}
return 0
}
@@ -0,0 +1,46 @@
package config
import (
"encoding/json"
"testing"
)
func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
document, err := loadRemoteConfig("")
if err != nil {
t.Fatal(err)
}
if document.SchemaVersion != 1 || document.ConfigVersion != 1 {
t.Fatalf("unexpected versions: %+v", document)
}
if document.Copy.Navigation.Favourites != "Favourites" {
t.Fatalf("favourites label = %q", document.Copy.Navigation.Favourites)
}
}
func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {
document := DefaultRemoteConfig()
document.Presentation.NavigationRailExpandedWidthDp = 500
raw, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
if _, err := loadRemoteConfig(string(raw)); err == nil {
t.Fatal("unsafe presentation value was accepted")
}
if _, err := loadRemoteConfig(`{"schemaVersion":1,"unknown":true}`); err == nil {
t.Fatal("unknown fields were accepted")
}
document = DefaultRemoteConfig()
document.MinimumAppVersion = "0.3.0"
document.MaximumAppVersion = "0.2.54"
raw, err = json.Marshal(document)
if err != nil {
t.Fatal(err)
}
if _, err := loadRemoteConfig(string(raw)); err == nil {
t.Fatal("reversed app-version bounds were accepted")
}
}
+149 -2
View File
@@ -2,9 +2,13 @@
package logging
import (
"bufio"
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -90,6 +94,18 @@ type Buffer struct {
events []Event
start int
next atomic.Int64
history *historyFile
}
// historyFile is an append-only JSONL archive of the same structured events the admin
// console reads. It is compacted to the ring's retained tail at startup and after each
// further ringful, so persistence cannot become an unbounded disk cost.
type historyFile struct {
mu sync.Mutex
path string
file *os.File
capacity int
lastCompacted int64
}
// ParseCapacity returns a non-negative log buffer capacity from configuration.
@@ -131,6 +147,69 @@ func NewBuffered(
return slog.New(&captureHandler{next: written, buffer: buffer, level: level}), buffer
}
// NewPersistentBuffered restores retained events from path before accepting new ones and
// appends every subsequent accepted record. The caller should close the returned Buffer.
// An empty path keeps the in-memory behaviour used by unit tests and small embeddings.
func NewPersistentBuffered(
w io.Writer, level slog.Leveler, capacity int, format Format, path string,
) (*slog.Logger, *Buffer, error) {
logger, buffer := NewBuffered(w, level, capacity, format)
path = strings.TrimSpace(path)
if capacity <= 0 || path == "" {
return logger, buffer, nil
}
history, restored, err := openHistory(path, capacity)
if err != nil {
return nil, nil, err
}
buffer.history = history
buffer.events = restored
if len(restored) > 0 {
buffer.next.Store(restored[len(restored)-1].Sequence)
}
return logger, buffer, nil
}
func openHistory(path string, capacity int) (*historyFile, []Event, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, nil, err
}
input, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0o640)
if err != nil {
return nil, nil, err
}
restored := make([]Event, 0, capacity)
scanner := bufio.NewScanner(input)
// Error attributes are capped upstream, but allow headroom for structured records.
scanner.Buffer(make([]byte, 64<<10), 1<<20)
for scanner.Scan() {
var event Event
if json.Unmarshal(scanner.Bytes(), &event) != nil || event.Sequence <= 0 {
continue
}
restored = append(restored, event)
if len(restored) > capacity {
copy(restored, restored[len(restored)-capacity:])
restored = restored[:capacity]
}
}
closeErr := input.Close()
if err := scanner.Err(); err != nil {
return nil, nil, err
}
if closeErr != nil {
return nil, nil, closeErr
}
history := &historyFile{path: path, capacity: capacity}
if len(restored) > 0 {
history.lastCompacted = restored[len(restored)-1].Sequence
}
if err := history.rewrite(restored); err != nil {
return nil, nil, err
}
return history, restored, nil
}
type captureHandler struct {
next slog.Handler
buffer *Buffer
@@ -197,9 +276,77 @@ func (b *Buffer) append(event Event) {
if len(b.events) == b.capacity {
b.events[b.start] = event
b.start = (b.start + 1) % b.capacity
return
}
} else {
b.events = append(b.events, event)
}
if b.history != nil {
ordered := b.orderedEventsLocked()
_ = b.history.append(event, ordered)
}
}
func (b *Buffer) orderedEventsLocked() []Event {
ordered := make([]Event, len(b.events))
for i := range b.events {
ordered[i] = b.events[(b.start+i)%len(b.events)]
}
return ordered
}
func (h *historyFile) append(event Event, retained []Event) error {
h.mu.Lock()
defer h.mu.Unlock()
if event.Sequence-h.lastCompacted >= int64(h.capacity) {
if err := h.rewriteLocked(retained); err != nil {
return err
}
h.lastCompacted = event.Sequence
return nil
}
return json.NewEncoder(h.file).Encode(event)
}
func (h *historyFile) rewrite(events []Event) error {
h.mu.Lock()
defer h.mu.Unlock()
return h.rewriteLocked(events)
}
func (h *historyFile) rewriteLocked(events []Event) error {
if h.file != nil {
if err := h.file.Close(); err != nil {
return err
}
}
file, err := os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
return err
}
encoder := json.NewEncoder(file)
for _, event := range events {
if err := encoder.Encode(event); err != nil {
_ = file.Close()
return err
}
}
if err := file.Close(); err != nil {
return err
}
h.file, err = os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
return err
}
// Close flushes the persistent archive. It is safe for an in-memory Buffer.
func (b *Buffer) Close() error {
if b == nil || b.history == nil {
return nil
}
b.history.mu.Lock()
defer b.history.mu.Unlock()
if b.history.file == nil {
return nil
}
return b.history.file.Close()
}
// Events returns records strictly newer than after, up to limit. If the caller fell
+27
View File
@@ -3,6 +3,7 @@ package logging
import (
"bytes"
"log/slog"
"path/filepath"
"strings"
"testing"
)
@@ -114,6 +115,32 @@ func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T)
}
}
func TestPersistentBufferRestoresTheRetainedTail(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
var output bytes.Buffer
logger, first, err := NewPersistentBuffered(&output, slog.LevelInfo, 3, FormatConsole, path)
if err != nil {
t.Fatal(err)
}
for i := 1; i <= 5; i++ {
logger.Info("request", "number", i)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
_, restored, err := NewPersistentBuffered(&output, slog.LevelInfo, 3, FormatConsole, path)
if err != nil {
t.Fatal(err)
}
defer restored.Close()
page := restored.Events(0, 10)
if len(page.Events) != 3 || page.Events[0].Attributes["number"] != "3" ||
page.Events[2].Attributes["number"] != "5" {
t.Fatalf("restored events = %+v", page.Events)
}
}
func TestParseLevel(t *testing.T) {
tests := map[string]slog.Level{
"": slog.LevelInfo,
+6 -4
View File
@@ -116,8 +116,10 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
return movies, nil
}
// AddUnmonitored adds a title without starting a search or monitoring future releases.
func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error) {
// AddRequested adds a title, monitors it and asks Radarr to search for it immediately.
// A request that merely creates an unmonitored catalogue row never reaches a downloader,
// which is indistinguishable from a broken button to the viewer who made it.
func (c *Client) AddRequested(ctx context.Context, movie Movie) (Movie, error) {
var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return Movie{}, err
@@ -132,11 +134,11 @@ func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error)
movie.ID = 0
movie.RootFolderPath = roots[0].Path
movie.QualityProfileID = profiles[0].ID
movie.Monitored = false
movie.Monitored = true
body := struct {
Movie
AddOptions map[string]bool `json:"addOptions"`
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": false}}
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": true}}
var added Movie
if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil {
return Movie{}, err
+6 -6
View File
@@ -48,7 +48,7 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
}
}
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/rootfolder":
@@ -60,13 +60,13 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["monitored"] != false || body["rootFolderPath"] != "/movies" ||
if body["monitored"] != true || body["rootFolderPath"] != "/movies" ||
body["qualityProfileId"] != float64(4) {
t.Errorf("unexpected add body: %#v", body)
}
options := body["addOptions"].(map[string]any)
if options["searchForMovie"] != false {
t.Errorf("movie search was enabled: %#v", body)
if options["searchForMovie"] != true {
t.Errorf("movie search was not enabled: %#v", body)
}
_, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`))
default:
@@ -75,8 +75,8 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
}))
defer upstream.Close()
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
context.Background(), Movie{TMDBID: 22, Title: "Arrival", Monitored: true},
added, err := New(upstream.URL, "secret", time.Second).AddRequested(
context.Background(), Movie{TMDBID: 22, Title: "Arrival"},
)
if err != nil {
t.Fatal(err)
+6 -5
View File
@@ -168,8 +168,9 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
return series, nil
}
// AddUnmonitored adds a series without monitoring it or starting an episode search.
func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, error) {
// AddRequested adds a series, monitors its seasons and asks Sonarr to search for missing
// episodes immediately. An unmonitored catalogue row does not fulfil a media request.
func (c *Client) AddRequested(ctx context.Context, series Series) (Series, error) {
var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return Series{}, err
@@ -184,15 +185,15 @@ func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, err
series.ID = 0
series.RootFolderPath = roots[0].Path
series.QualityProfileID = profiles[0].ID
series.Monitored = false
series.Monitored = true
series.SeasonFolder = true
for i := range series.Seasons {
series.Seasons[i].Monitored = false
series.Seasons[i].Monitored = true
}
body := struct {
Series
AddOptions map[string]bool `json:"addOptions"`
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": false}}
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": true}}
var added Series
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
return Series{}, err
+9 -9
View File
@@ -46,7 +46,7 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
}
}
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/rootfolder":
@@ -58,17 +58,17 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["monitored"] != false || body["seasonFolder"] != true ||
if body["monitored"] != true || body["seasonFolder"] != true ||
body["rootFolderPath"] != "/tv" || body["qualityProfileId"] != float64(3) {
t.Errorf("unexpected add body: %#v", body)
}
seasons := body["seasons"].([]any)
if seasons[0].(map[string]any)["monitored"] != false {
t.Errorf("season remained monitored: %#v", body)
if seasons[0].(map[string]any)["monitored"] != true {
t.Errorf("season was not monitored: %#v", body)
}
options := body["addOptions"].(map[string]any)
if options["searchForMissingEpisodes"] != false {
t.Errorf("episode search was enabled: %#v", body)
if options["searchForMissingEpisodes"] != true {
t.Errorf("episode search was not enabled: %#v", body)
}
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
default:
@@ -77,10 +77,10 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
}))
defer upstream.Close()
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
added, err := New(upstream.URL, "secret", time.Second).AddRequested(
context.Background(), Series{
TVDBID: 44, Title: "Severance", Monitored: true,
Seasons: []Season{{SeasonNumber: 1, Monitored: true}},
TVDBID: 44, Title: "Severance",
Seasons: []Season{{SeasonNumber: 1}},
},
)
if err != nil {