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. # playback progress reports and search terms.
MEMBY_LOG_LEVEL=INFO MEMBY_LOG_LEVEL=INFO
MEMBY_LOG_BUFFER_CAPACITY=5000 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. # 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. # logfmt restores slog's own key=value line; json is for a log collector.
MEMBY_LOG_FORMAT=console 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={"ExplorationRate":0.08,"MinimumEvidence":2,"ImpressionPenalty":0.12}
MEMBY_RECOMMENDATION_WEIGHTS= 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. # Maximum number of distinct Memby TVs one Emby user may keep signed in.
# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the # 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 — # 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 # it needs only an API key, and the credentials live in the database rather than in this
# file. # file.
MEMBY_BAZARR_URL=http://10.0.0.2:6767 #MEMBY_BAZARR_URL=http://10.0.0.2:6767
MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11 #MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
# How long Bazarr's movie/series/episode listings are cached. They exist only to turn an # 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. # 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 # 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 # longer timeout than the rest of the upstreams. Too short reads to a viewer as "no
# subtitles found" rather than as a timeout. # 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 (`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 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. `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**. - `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 `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 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`). `resumePositionMs`).
**A detail page is warmed while its card is focused, in two waves.** By the time somebody **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 presses a card, the item record and its "why you might enjoy it" have been fetched — and now
been fetched — and now so have its episode list and its trailer, which were the two things 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 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 episode and pressing one opened a page with no season scroller and no episode list until the
network answered. Things to preserve: 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 - **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 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 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.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
import com.ponzischeme89.memby.update.RequiredUpdateGuard import com.ponzischeme89.memby.update.RequiredUpdateGuard
/** /**
@@ -25,6 +26,8 @@ object ServiceLocator {
private set private set
lateinit var maintenance: MaintenanceMonitor lateinit var maintenance: MaintenanceMonitor
private set private set
lateinit var remoteConfig: RemoteConfigManager
private set
/** /**
* Held rather than discarded because it is a long-lived collector, not a service * 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 // 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. // request or playback negotiation asks what this television's receiver accepts.
installAudioCapabilityProbe(context) 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) settings = SettingsStore(context.applicationContext)
// Started before the repository, so a refusal answering the very first request a // Started before the repository, so a refusal answering the very first request a
// television makes has somewhere to be recorded. // 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, // 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. // the screensaver's DreamService — are separate roots with no common owner but this.
themeSync = ThemeSync(repository, settings, maintenance.theme) themeSync = ThemeSync(repository, settings, maintenance.theme)
remoteConfig.refreshLater()
} }
} }
@@ -872,6 +872,7 @@ class EmbyRepository(private val settings: SettingsStore) {
com.ponzischeme89.memby.data.model.GatewayMediaRequest( com.ponzischeme89.memby.data.model.GatewayMediaRequest(
mediaType = candidate.mediaType, mediaType = candidate.mediaType,
foreignId = candidate.foreignId, foreignId = candidate.foreignId,
title = candidate.title,
), ),
).title ).title
} }
@@ -446,6 +446,7 @@ data class GatewayRequestLookup(
data class GatewayMediaRequest( data class GatewayMediaRequest(
val mediaType: String, val mediaType: String,
val foreignId: Int, val foreignId: Int,
val title: String,
) )
@Serializable @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.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.ValueSeparator 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 java.util.Locale
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -236,6 +239,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
@Composable @Composable
fun TvNavigationRail( fun TvNavigationRail(
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
selected: BrowseDestination, selected: BrowseDestination,
focusDestination: BrowseDestination = selected, focusDestination: BrowseDestination = selected,
expanded: Boolean, expanded: Boolean,
@@ -269,7 +273,7 @@ fun TvNavigationRail(
} }
} }
val railWidth = animateDpAsState( val railWidth = animateDpAsState(
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth, targetValue = if (expanded) config.expandedWidthDp.dp else TvRailCollapsedWidth,
animationSpec = tween(160), animationSpec = tween(160),
label = "navigation-rail-width", label = "navigation-rail-width",
) )
@@ -339,7 +343,7 @@ fun TvNavigationRail(
maxLines = 1, maxLines = 1,
) )
Text( Text(
"Matts Android TV client", config.tagline,
color = QuietText, color = QuietText,
fontSize = 9.sp, fontSize = 9.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -364,7 +368,7 @@ fun TvNavigationRail(
label = if (destination == BrowseDestination.PROFILES) { label = if (destination == BrowseDestination.PROFILES) {
profileDestinationLabel(activeUsername) profileDestinationLabel(activeUsername)
} else { } else {
destination.label configuredNavigationLabel(destination, config.labels)
}, },
selected = destination == selected, selected = destination == selected,
expanded = expanded, expanded = expanded,
@@ -389,18 +393,35 @@ fun TvNavigationRail(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Text( if (config.showVersion) {
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}", Text(
color = QuietText, if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
fontSize = if (expanded) 10.sp else 8.sp, color = QuietText,
fontWeight = FontWeight.Medium, fontSize = if (expanded) 10.sp else 8.sp,
maxLines = 1, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp), maxLines = 1,
) modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
)
}
} }
} }
} }
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 @Composable
fun UserSwitcherOverlay( fun UserSwitcherOverlay(
profiles: List<EmbyProfile>, profiles: List<EmbyProfile>,
@@ -389,14 +389,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
metadataJob = viewModelScope.launch(Dispatchers.IO) { metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS) delay(FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope { coroutineScope {
// Resolution is tiny compared with video buffering and makes the later // Do not negotiate playback on focus. Emby creates a playback session as
// Play click a memory lookup. The repository single-flights requests, // part of PlaybackInfo, so warming a stream here made merely browsing a
// so focus and click can never duplicate the gateway call. // shelf appear in server history as something the viewer had played.
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) } }
}
// Warm the explanation and franchise siblings while the card is already // Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later. // focused, so opening Details does not add a reason line a frame later.
if (!item.isSchedule && (item.isMovie || item.isSeries)) { 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. * 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 * 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 * 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 * 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 * 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.UserNotification
import com.ponzischeme89.memby.data.model.RecommendationPerson import com.ponzischeme89.memby.data.model.RecommendationPerson
import com.ponzischeme89.memby.data.ServerConfig 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.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor import com.ponzischeme89.memby.ui.detail.airingNoticeFor
@@ -246,8 +247,11 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Captured before composition and never observed as state: a downloaded document
// is for the next process, never a label change under the viewer's focus.
val remoteConfig = ServiceLocator.remoteConfig.active
setContent { setContent {
MembyTheme { AppRoot(onCloseSettings = ::finish) } MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) }
} }
PerformanceMonitor.start(this) 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 private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
@Composable @Composable
private fun AppRoot(onCloseSettings: () -> Unit) { private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val context = LocalContext.current val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active // 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() if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
} }
key(loaded.userId, loaded.serverUrl) { 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 // 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 // launcher and nowhere else. Not over playback — a film is the one thing
@@ -1887,6 +1891,7 @@ private fun ProfileTile(
@Composable @Composable
private fun HomeScreen( private fun HomeScreen(
settings: Settings, settings: Settings,
remoteConfig: MembyRemoteConfig,
) { ) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val context = LocalContext.current val context = LocalContext.current
@@ -2347,13 +2352,14 @@ private fun HomeScreen(
} }
val contentShift by animateDpAsState( val contentShift by animateDpAsState(
targetValue = if (navigationExpanded) TvRailContentShift else 0.dp, targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp,
animationSpec = tween(150), animationSpec = tween(150),
label = "navigation-content-shift", label = "navigation-content-shift",
) )
Box(Modifier.fillMaxSize().background(MembySurface)) { Box(Modifier.fillMaxSize().background(MembySurface)) {
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
TvNavigationRail( TvNavigationRail(
config = remoteConfig.navigation,
selected = selectedDestination, selected = selectedDestination,
focusDestination = railFocusDestination, focusDestination = railFocusDestination,
expanded = navigationExpanded, expanded = navigationExpanded,
@@ -316,7 +316,7 @@ internal fun MyShowDetailsOverlay(
) )
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) 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("Next episode", formatMyShowDate(show.nextEpisode))
StatusLine("Series status", show.lifecycle) StatusLine("Series status", show.lifecycle)
Row( Row(
@@ -156,7 +156,7 @@ internal fun CalendarContent(
state.errorMessage.orEmpty(), "Try again", onRetry, state.errorMessage.orEmpty(), "Try again", onRetry,
) )
!state.calendar.available -> AgendaNotice( !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.") week == null -> AgendaNotice("Nothing scheduled.")
else -> { else -> {
@@ -1029,7 +1029,7 @@ private fun RequestOptions(
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold) Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text( 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, color = Muted,
fontSize = 13.sp, fontSize = 13.sp,
maxLines = 2, 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. # health checks, maintenance polling and artwork requests.
MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}" MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}"
MEMBY_LOG_BUFFER_CAPACITY: "${MEMBY_LOG_BUFFER_CAPACITY:-5000}" 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. # console is one aligned, readable line per event; logfmt and json are for tools.
MEMBY_LOG_FORMAT: "${MEMBY_LOG_FORMAT:-console}" MEMBY_LOG_FORMAT: "${MEMBY_LOG_FORMAT:-console}"
GOMEMLIMIT: "${MEMBY_GOMEMLIMIT:-384MiB}" GOMEMLIMIT: "${MEMBY_GOMEMLIMIT:-384MiB}"
@@ -29,6 +31,9 @@ services:
MEMBY_REDIS_URL: "redis://redis:6379/0" MEMBY_REDIS_URL: "redis://redis:6379/0"
MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}" MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}"
MEMBY_RECOMMEND_TTL: "${MEMBY_RECOMMEND_TTL:-24h}" 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 # Strict per-Emby-user TV allowance. Signing the same physical TV in again
# replaces its token and does not consume another slot. # replaces its token and does not consume another slot.
# Unset disables /admin entirely — the library import, maintenance switch and # Unset disables /admin entirely — the library import, maintenance switch and
@@ -68,6 +73,7 @@ services:
mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}" mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}"
volumes: volumes:
- memby-releases:/data/releases - memby-releases:/data/releases
- memby-logs:/data/logs
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -110,3 +116,4 @@ services:
volumes: volumes:
memby-postgres: memby-postgres:
memby-releases: memby-releases:
memby-logs:
+2 -1
View File
@@ -8,7 +8,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN mkdir -p /out/releases RUN mkdir -p /out/releases /out/logs
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server -ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
@@ -18,6 +18,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app WORKDIR /app
COPY --from=build /out/memby-server /app/memby-server 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/releases /data/releases
COPY --from=build --chown=nonroot:nonroot /out/logs /data/logs
EXPOSE 8080 EXPOSE 8080
USER nonroot:nonroot USER nonroot:nonroot
ENTRYPOINT ["/app/memby-server"] 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` 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. 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 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 `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. 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_REDIS_URL` | `redis://localhost:6379/0` | |
| `MEMBY_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | | | `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_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_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` |
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container | | `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 | | `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")) logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000) logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000)
logFormat := logging.ParseFormat(os.Getenv("MEMBY_LOG_FORMAT")) 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 // 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 // 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. // 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 mdblist = status.mdblist || {};
const forYou = status.forYou || {}; const forYou = status.forYou || {};
$('overview-services').innerHTML = $('overview-services').innerHTML =
row('Radarr', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) + row('Movies', 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('Series', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('MDBList ratings', mdblist.enabled row('MDBList ratings', mdblist.enabled
? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok') ? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok')
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) + : ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) +
@@ -2,8 +2,8 @@
<div class="card-head split"> <div class="card-head split">
<div> <div>
<h2 class="card-title" data-icon="inbox" data-icon-tone="info">Where a request goes</h2> <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. <p class="card-note">A movie or series is monitored and searched for immediately.
No download search starts on its own.</p> The configured download service handles it from there.</p>
</div> </div>
<span class="row tight" id="request-services"></span> <span class="row tight" id="request-services"></span>
</div> </div>
+2 -2
View File
@@ -2,9 +2,9 @@ const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => { Admin.onStatus((status) => {
$('request-services').innerHTML = $('request-services').innerHTML =
ui.tag('Radarr ' + (status.radarrReady ? 'ready' : 'not configured'), ui.tag('Movies ' + (status.radarrReady ? 'ready' : 'not configured'),
status.radarrReady ? 'ok' : 'bad') + status.radarrReady ? 'ok' : 'bad') +
ui.tag('Sonarr ' + (status.sonarrReady ? 'ready' : 'not configured'), ui.tag('Series ' + (status.sonarrReady ? 'ready' : 'not configured'),
status.sonarrReady ? 'ok' : 'bad'); status.sonarrReady ? 'ok' : 'bad');
const box = $('request-users'); const box = $('request-users');
+4
View File
@@ -205,6 +205,10 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth) mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady) 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 // 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 // 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. // 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 { type requestPayload struct {
MediaType string `json:"mediaType"` MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"` ForeignID int `json:"foreignId"`
Title string `json:"title"`
} }
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess store.Session) { 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") writeError(w, http.StatusBadRequest, "foreignId is required")
return return
} }
req.Title = strings.TrimSpace(req.Title)
if titleRunes := []rune(req.Title); len(titleRunes) > 240 {
req.Title = string(titleRunes[:240])
}
switch req.MediaType { switch req.MediaType {
case "movie": case "movie":
if s.radarr == nil { 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 return
} }
movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID)) movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID))
if err != nil { 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 return
} }
for _, movie := range movies { 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 // 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 // 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. // 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}) writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
return return
} }
added, err := s.radarr.AddUnmonitored(r.Context(), movie) req.Title = movie.Title
added, err := s.radarr.AddRequested(r.Context(), movie)
if err != nil { 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 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}) writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return return
} }
case "series": case "series":
if s.sonarr == nil { 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 return
} }
series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID)) series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID))
if err != nil { 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 return
} }
for _, show := range series { for _, show := range series {
@@ -232,25 +246,49 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
continue continue
} }
if show.ID > 0 { 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}) writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
return return
} }
added, err := s.sonarr.AddUnmonitored(r.Context(), show) req.Title = show.Title
added, err := s.sonarr.AddRequested(r.Context(), show)
if err != nil { 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 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}) writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return return
} }
default: default:
s.logMediaRequest(r.Context(), req, "failed", errors.New("unsupported media type"))
writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`) writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`)
return return
} }
s.logMediaRequest(r.Context(), req, "failed", errors.New("title was not found"))
writeError(w, http.StatusNotFound, "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( func (s *Server) writeRequestUpstreamError(
ctx context.Context, w http.ResponseWriter, err error, message string, ctx context.Context, w http.ResponseWriter, err error, message string,
) { ) {
+27 -1
View File
@@ -1,6 +1,14 @@
package api package api
import "testing" import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
)
func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) { func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
if requestMatchScore("the office", "The Office") >= 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") 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 RecommendTimeout time.Duration
// RecommendationWeights is an optional JSON overlay on the weighted defaults. // RecommendationWeights is an optional JSON overlay on the weighted defaults.
RecommendationWeights string 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 UpstreamTimeout time.Duration
@@ -138,6 +141,10 @@ type Config struct {
} }
func Load() (Config, error) { func Load() (Config, error) {
remoteConfig, err := loadRemoteConfig(os.Getenv("MEMBY_REMOTE_CONFIG_JSON"))
if err != nil {
return Config{}, err
}
c := Config{ c := Config{
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"), ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"), EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
@@ -156,6 +163,7 @@ func Load() (Config, error) {
RecommendationWeights: strings.TrimSpace( RecommendationWeights: strings.TrimSpace(
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"), os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
), ),
RemoteConfig: remoteConfig,
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")), AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"), 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 package logging
import ( import (
"bufio"
"context" "context"
"encoding/json"
"io" "io"
"log/slog" "log/slog"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -90,6 +94,18 @@ type Buffer struct {
events []Event events []Event
start int start int
next atomic.Int64 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. // 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 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 { type captureHandler struct {
next slog.Handler next slog.Handler
buffer *Buffer buffer *Buffer
@@ -197,9 +276,77 @@ func (b *Buffer) append(event Event) {
if len(b.events) == b.capacity { if len(b.events) == b.capacity {
b.events[b.start] = event b.events[b.start] = event
b.start = (b.start + 1) % b.capacity b.start = (b.start + 1) % b.capacity
return } else {
b.events = append(b.events, event)
} }
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 // Events returns records strictly newer than after, up to limit. If the caller fell
+27
View File
@@ -3,6 +3,7 @@ package logging
import ( import (
"bytes" "bytes"
"log/slog" "log/slog"
"path/filepath"
"strings" "strings"
"testing" "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) { func TestParseLevel(t *testing.T) {
tests := map[string]slog.Level{ tests := map[string]slog.Level{
"": slog.LevelInfo, "": slog.LevelInfo,
+6 -4
View File
@@ -116,8 +116,10 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
return movies, nil return movies, nil
} }
// AddUnmonitored adds a title without starting a search or monitoring future releases. // AddRequested adds a title, monitors it and asks Radarr to search for it immediately.
func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error) { // 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 var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil { if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return Movie{}, err return Movie{}, err
@@ -132,11 +134,11 @@ func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error)
movie.ID = 0 movie.ID = 0
movie.RootFolderPath = roots[0].Path movie.RootFolderPath = roots[0].Path
movie.QualityProfileID = profiles[0].ID movie.QualityProfileID = profiles[0].ID
movie.Monitored = false movie.Monitored = true
body := struct { body := struct {
Movie Movie
AddOptions map[string]bool `json:"addOptions"` 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 var added Movie
if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil { if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil {
return Movie{}, err 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) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/api/v3/rootfolder": case "/api/v3/rootfolder":
@@ -60,13 +60,13 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if body["monitored"] != false || body["rootFolderPath"] != "/movies" || if body["monitored"] != true || body["rootFolderPath"] != "/movies" ||
body["qualityProfileId"] != float64(4) { body["qualityProfileId"] != float64(4) {
t.Errorf("unexpected add body: %#v", body) t.Errorf("unexpected add body: %#v", body)
} }
options := body["addOptions"].(map[string]any) options := body["addOptions"].(map[string]any)
if options["searchForMovie"] != false { if options["searchForMovie"] != true {
t.Errorf("movie search was enabled: %#v", body) t.Errorf("movie search was not enabled: %#v", body)
} }
_, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`)) _, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`))
default: default:
@@ -75,8 +75,8 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
})) }))
defer upstream.Close() defer upstream.Close()
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored( added, err := New(upstream.URL, "secret", time.Second).AddRequested(
context.Background(), Movie{TMDBID: 22, Title: "Arrival", Monitored: true}, context.Background(), Movie{TMDBID: 22, Title: "Arrival"},
) )
if err != nil { if err != nil {
t.Fatal(err) 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 return series, nil
} }
// AddUnmonitored adds a series without monitoring it or starting an episode search. // AddRequested adds a series, monitors its seasons and asks Sonarr to search for missing
func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, error) { // 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 var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil { if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return Series{}, err return Series{}, err
@@ -184,15 +185,15 @@ func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, err
series.ID = 0 series.ID = 0
series.RootFolderPath = roots[0].Path series.RootFolderPath = roots[0].Path
series.QualityProfileID = profiles[0].ID series.QualityProfileID = profiles[0].ID
series.Monitored = false series.Monitored = true
series.SeasonFolder = true series.SeasonFolder = true
for i := range series.Seasons { for i := range series.Seasons {
series.Seasons[i].Monitored = false series.Seasons[i].Monitored = true
} }
body := struct { body := struct {
Series Series
AddOptions map[string]bool `json:"addOptions"` 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 var added Series
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil { if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
return Series{}, err 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) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/api/v3/rootfolder": case "/api/v3/rootfolder":
@@ -58,17 +58,17 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if body["monitored"] != false || body["seasonFolder"] != true || if body["monitored"] != true || body["seasonFolder"] != true ||
body["rootFolderPath"] != "/tv" || body["qualityProfileId"] != float64(3) { body["rootFolderPath"] != "/tv" || body["qualityProfileId"] != float64(3) {
t.Errorf("unexpected add body: %#v", body) t.Errorf("unexpected add body: %#v", body)
} }
seasons := body["seasons"].([]any) seasons := body["seasons"].([]any)
if seasons[0].(map[string]any)["monitored"] != false { if seasons[0].(map[string]any)["monitored"] != true {
t.Errorf("season remained monitored: %#v", body) t.Errorf("season was not monitored: %#v", body)
} }
options := body["addOptions"].(map[string]any) options := body["addOptions"].(map[string]any)
if options["searchForMissingEpisodes"] != false { if options["searchForMissingEpisodes"] != true {
t.Errorf("episode search was enabled: %#v", body) t.Errorf("episode search was not enabled: %#v", body)
} }
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`)) _, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
default: default:
@@ -77,10 +77,10 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
})) }))
defer upstream.Close() defer upstream.Close()
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored( added, err := New(upstream.URL, "secret", time.Second).AddRequested(
context.Background(), Series{ context.Background(), Series{
TVDBID: 44, Title: "Severance", Monitored: true, TVDBID: 44, Title: "Severance",
Seasons: []Season{{SeasonNumber: 1, Monitored: true}}, Seasons: []Season{{SeasonNumber: 1}},
}, },
) )
if err != nil { if err != nil {