diff --git a/.env.example b/.env.example index b9114b9..00bcf2b 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 1502abf..65d3dc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt index a4ec7f9..cc49e72 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -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() } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index bb35985..2c69301 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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 } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 0c41292..6d3a752 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -446,6 +446,7 @@ data class GatewayRequestLookup( data class GatewayMediaRequest( val mediaType: String, val foreignId: Int, + val title: String, ) @Serializable diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt b/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt new file mode 100644 index 0000000..657e082 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt @@ -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 = "Matt’s 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(raw) +}.getOrNull() + +private fun decodeCachedRemoteConfig(raw: String): CachedRemoteConfig? = runCatching { + remoteConfigJson.decodeFromString(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 { + 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]) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index de4662c..ad9d4e0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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( - "Matt’s 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,18 +393,35 @@ fun TvNavigationRail( Spacer(Modifier.height(4.dp)) } Spacer(Modifier.weight(1f)) - Text( - if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}", - color = QuietText, - fontSize = if (expanded) 10.sp else 8.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp), - ) + if (config.showVersion) { + Text( + if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}", + color = QuietText, + fontSize = if (expanded) 10.sp else 8.sp, + fontWeight = FontWeight.Medium, + 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 fun UserSwitcherOverlay( profiles: List, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 08eea88..8c7e264 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 5bef2a9..0b81101 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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, 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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index 1703da2..baf934b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt index dfec3fa..b2798b3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt @@ -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 -> { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 41027da..bd612e0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -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, diff --git a/app/src/test/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfigTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfigTest.kt new file mode 100644 index 0000000..3c7b327 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfigTest.kt @@ -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() + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 9bb9452..14a4aec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/server/Dockerfile b/server/Dockerfile index 964cf0a..74178e3 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md index f819dd2..92d7cf7 100644 --- a/server/README.md +++ b/server/README.md @@ -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 | diff --git a/server/REMOTE_CONFIG.md b/server/REMOTE_CONFIG.md new file mode 100644 index 0000000..63c7e68 --- /dev/null +++ b/server/REMOTE_CONFIG.md @@ -0,0 +1,48 @@ +# Remote Config + +Memby’s 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 deployment’s `.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. diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index 5716a5b..942b528 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -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. diff --git a/server/internal/api/admin/pages/overview.js b/server/internal/api/admin/pages/overview.js index cdb2e57..f943312 100644 --- a/server/internal/api/admin/pages/overview.js +++ b/server/internal/api/admin/pages/overview.js @@ -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')) + diff --git a/server/internal/api/admin/pages/requests.html b/server/internal/api/admin/pages/requests.html index ac6ac48..c116b7a 100644 --- a/server/internal/api/admin/pages/requests.html +++ b/server/internal/api/admin/pages/requests.html @@ -2,8 +2,8 @@

Where a request goes

-

A film is added to Radarr and a show to Sonarr, both unmonitored. - No download search starts on its own.

+

A movie or series is monitored and searched for immediately. + The configured download service handles it from there.

diff --git a/server/internal/api/admin/pages/requests.js b/server/internal/api/admin/pages/requests.js index 1a2e1c8..e17625b 100644 --- a/server/internal/api/admin/pages/requests.js +++ b/server/internal/api/admin/pages/requests.js @@ -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'); diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 034c7f3..6fceffa 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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. diff --git a/server/internal/api/remote_config.go b/server/internal/api/remote_config.go new file mode 100644 index 0000000..af10596 --- /dev/null +++ b/server/internal/api/remote_config.go @@ -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) +} diff --git a/server/internal/api/remote_config_test.go b/server/internal/api/remote_config_test.go new file mode 100644 index 0000000..ccc8dd7 --- /dev/null +++ b/server/internal/api/remote_config_test.go @@ -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) + } +} diff --git a/server/internal/api/requests.go b/server/internal/api/requests.go index f13f4bd..a08f5a0 100644 --- a/server/internal/api/requests.go +++ b/server/internal/api/requests.go @@ -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, ) { diff --git a/server/internal/api/requests_test.go b/server/internal/api/requests_test.go index 103ffdf..c25422a 100644 --- a/server/internal/api/requests_test.go +++ b/server/internal/api/requests_test.go @@ -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) + } + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 8b5f399..46524b3 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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")), "/"), diff --git a/server/internal/config/remote_config.go b/server/internal/config/remote_config.go new file mode 100644 index 0000000..8def9bc --- /dev/null +++ b/server/internal/config/remote_config.go @@ -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: "Matt’s 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 +} diff --git a/server/internal/config/remote_config_test.go b/server/internal/config/remote_config_test.go new file mode 100644 index 0000000..cd08f82 --- /dev/null +++ b/server/internal/config/remote_config_test.go @@ -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") + } +} diff --git a/server/internal/logging/logging.go b/server/internal/logging/logging.go index a87ffd3..e7b14b7 100644 --- a/server/internal/logging/logging.go +++ b/server/internal/logging/logging.go @@ -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) } - 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 diff --git a/server/internal/logging/logging_test.go b/server/internal/logging/logging_test.go index f33dffb..ded5f78 100644 --- a/server/internal/logging/logging_test.go +++ b/server/internal/logging/logging_test.go @@ -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, diff --git a/server/internal/radarr/client.go b/server/internal/radarr/client.go index 73227c7..2625cff 100644 --- a/server/internal/radarr/client.go +++ b/server/internal/radarr/client.go @@ -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 diff --git a/server/internal/radarr/client_test.go b/server/internal/radarr/client_test.go index 7bec979..afe0c5d 100644 --- a/server/internal/radarr/client_test.go +++ b/server/internal/radarr/client_test.go @@ -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) diff --git a/server/internal/sonarr/client.go b/server/internal/sonarr/client.go index 5d4962b..b74cd89 100644 --- a/server/internal/sonarr/client.go +++ b/server/internal/sonarr/client.go @@ -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 diff --git a/server/internal/sonarr/client_test.go b/server/internal/sonarr/client_test.go index 94f02c4..1eb7460 100644 --- a/server/internal/sonarr/client_test.go +++ b/server/internal/sonarr/client_test.go @@ -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 {