0.2.55 - Remote config/Request fixes
This commit is contained in:
+9
-3
@@ -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.
|
||||
|
||||
@@ -591,6 +591,8 @@ pieces make that true and each is easy to undo:
|
||||
(`component`, `user`, `device`, `client`) first so it can be read as a column, the
|
||||
constant `version` and the `error` last. `MEMBY_LOG_FORMAT` switches to `logfmt` or
|
||||
`json`; the ring buffer the admin page reads is fed the same records in every format.
|
||||
That ring is restored from a bounded JSONL archive in the persistent `memby-logs`
|
||||
volume, so a deployment replaces the process without erasing the operator's history.
|
||||
- `internal/api/logcontext.go` carries a **`*requestIdentity` in the request context**.
|
||||
`withLogging` creates it from the route and the client headers; `authed` fills in the
|
||||
viewer and television once the session resolves; both the handler's own events
|
||||
@@ -714,12 +716,16 @@ Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks
|
||||
`resumePositionMs`).
|
||||
|
||||
**A detail page is warmed while its card is focused, in two waves.** By the time somebody
|
||||
presses a card, the item record, its "why you might enjoy it" and its playable URL have all
|
||||
been fetched — and now so have its episode list and its trailer, which were the two things
|
||||
presses a card, the item record and its "why you might enjoy it" have been fetched — and now
|
||||
so have its episode list and its trailer, which were the two things
|
||||
the page still opened cold. That mattered most on Continue Watching, where every card is an
|
||||
episode and pressing one opened a page with no season scroller and no episode list until the
|
||||
network answered. Things to preserve:
|
||||
|
||||
- **Playback itself is never warmed on focus.** Emby's PlaybackInfo negotiation creates a
|
||||
playback session, so asking for it while somebody merely browses pollutes server history
|
||||
with titles they never played. Stream resolution begins only after a Play action.
|
||||
|
||||
- **The two waves have deliberately different delays.** The metadata warm follows the D-pad
|
||||
closely at `FOCUS_METADATA_DEBOUNCE_MS` (140 ms) because it decides what the panel beside
|
||||
the row says; `warmDetailPage` waits `DETAIL_PREFETCH_DELAY_MS` (450 ms) because an
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync
|
||||
import com.ponzischeme89.memby.data.SettingsStore
|
||||
import com.ponzischeme89.memby.data.ThemeSync
|
||||
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
|
||||
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateGuard
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,8 @@ object ServiceLocator {
|
||||
private set
|
||||
lateinit var maintenance: MaintenanceMonitor
|
||||
private set
|
||||
lateinit var remoteConfig: RemoteConfigManager
|
||||
private set
|
||||
|
||||
/**
|
||||
* Held rather than discarded because it is a long-lived collector, not a service
|
||||
@@ -54,6 +57,9 @@ object ServiceLocator {
|
||||
// Only hands the probe an application context; it does no work until the first
|
||||
// request or playback negotiation asks what this television's receiver accepts.
|
||||
installAudioCapabilityProbe(context)
|
||||
// One tiny synchronous preference read selects an immutable process snapshot.
|
||||
// Network revalidation is delayed and can only affect the next process.
|
||||
remoteConfig = RemoteConfigManager(context.applicationContext)
|
||||
settings = SettingsStore(context.applicationContext)
|
||||
// Started before the repository, so a refusal answering the very first request a
|
||||
// television makes has somewhere to be recorded.
|
||||
@@ -69,5 +75,6 @@ object ServiceLocator {
|
||||
// because the surfaces that obey it — the launcher, the player's Compose islands,
|
||||
// the screensaver's DreamService — are separate roots with no common owner but this.
|
||||
themeSync = ThemeSync(repository, settings, maintenance.theme)
|
||||
remoteConfig.refreshLater()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,6 +872,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
|
||||
mediaType = candidate.mediaType,
|
||||
foreignId = candidate.foreignId,
|
||||
title = candidate.title,
|
||||
),
|
||||
).title
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ data class GatewayRequestLookup(
|
||||
data class GatewayMediaRequest(
|
||||
val mediaType: String,
|
||||
val foreignId: Int,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.ponzischeme89.memby.data.remoteconfig
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
|
||||
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
|
||||
private const val REMOTE_CONFIG_START_DELAY_MS = 5_000L
|
||||
private const val MAX_REMOTE_CONFIG_BYTES = 32L * 1024L
|
||||
private const val PREFERENCES_NAME = "memby_remote_config"
|
||||
private const val DOCUMENT_KEY = "last_known_good"
|
||||
|
||||
/**
|
||||
* Typed, process-stable presentation configuration.
|
||||
*
|
||||
* The active value is chosen once, before any Activity is created. A successful refresh
|
||||
* only replaces the durable last-known-good document; it deliberately does not publish a
|
||||
* Flow or mutable state that could repaint wording under somebody's focus.
|
||||
*/
|
||||
@Serializable
|
||||
data class MembyRemoteConfig(
|
||||
val schemaVersion: Int,
|
||||
val configVersion: Long,
|
||||
val minimumAppVersion: String = "",
|
||||
val maximumAppVersion: String = "",
|
||||
val copy: RemoteCopy,
|
||||
val features: RemoteFeatures,
|
||||
val presentation: RemotePresentation,
|
||||
) {
|
||||
val navigation: NavigationRemoteConfig
|
||||
get() = NavigationRemoteConfig(
|
||||
labels = copy.navigation,
|
||||
tagline = copy.tagline,
|
||||
showVersion = features.showNavigationVersion,
|
||||
expandedWidthDp = presentation.navigationRailExpandedWidthDp,
|
||||
contentShiftDp = presentation.navigationContentShiftDp,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RemoteCopy(
|
||||
val navigation: NavigationLabels,
|
||||
val tagline: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NavigationLabels(
|
||||
val home: String,
|
||||
val forYou: String,
|
||||
val search: String,
|
||||
val movies: String,
|
||||
val tvShows: String,
|
||||
val tvCalendar: String,
|
||||
val favourites: String,
|
||||
val user: String,
|
||||
val settings: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RemoteFeatures(
|
||||
val showNavigationVersion: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RemotePresentation(
|
||||
val navigationRailExpandedWidthDp: Int,
|
||||
val navigationContentShiftDp: Int,
|
||||
)
|
||||
|
||||
/** The single UI-facing projection, so Compose never performs stringly typed lookups. */
|
||||
data class NavigationRemoteConfig(
|
||||
val labels: NavigationLabels,
|
||||
val tagline: String,
|
||||
val showVersion: Boolean,
|
||||
val expandedWidthDp: Int,
|
||||
val contentShiftDp: Int,
|
||||
)
|
||||
|
||||
object BundledRemoteConfig {
|
||||
val value = MembyRemoteConfig(
|
||||
schemaVersion = REMOTE_CONFIG_SCHEMA_VERSION,
|
||||
// Bundled data is the floor, not a remotely issued revision. Any valid server
|
||||
// document therefore wins on a later process start.
|
||||
configVersion = 0,
|
||||
copy = RemoteCopy(
|
||||
tagline = "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<MembyRemoteConfig>(raw)
|
||||
}.getOrNull()
|
||||
|
||||
private fun decodeCachedRemoteConfig(raw: String): CachedRemoteConfig? = runCatching {
|
||||
remoteConfigJson.decodeFromString<CachedRemoteConfig>(raw)
|
||||
}.getOrNull()
|
||||
|
||||
/** Returns null only when the whole document is safe to activate. */
|
||||
internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: String): String? {
|
||||
if (document.schemaVersion != REMOTE_CONFIG_SCHEMA_VERSION) return "unsupported schema"
|
||||
if (document.configVersion < 1) return "invalid configuration version"
|
||||
val current = semanticVersion(appVersion) ?: return "invalid app version"
|
||||
document.minimumAppVersion.takeIf(String::isNotBlank)?.let { minimum ->
|
||||
val parsed = semanticVersion(minimum) ?: return "invalid minimum app version"
|
||||
if (current < parsed) return "app is older than the configuration minimum"
|
||||
}
|
||||
document.maximumAppVersion.takeIf(String::isNotBlank)?.let { maximum ->
|
||||
val parsed = semanticVersion(maximum) ?: return "invalid maximum app version"
|
||||
if (current > parsed) return "app is newer than the configuration maximum"
|
||||
}
|
||||
val copy = document.copy
|
||||
val labels = listOf(
|
||||
copy.tagline,
|
||||
copy.navigation.home,
|
||||
copy.navigation.forYou,
|
||||
copy.navigation.search,
|
||||
copy.navigation.movies,
|
||||
copy.navigation.tvShows,
|
||||
copy.navigation.tvCalendar,
|
||||
copy.navigation.favourites,
|
||||
copy.navigation.user,
|
||||
copy.navigation.settings,
|
||||
)
|
||||
if (labels.any { it.isBlank() || it != it.trim() || it.length > 64 || it.any(Char::isISOControl) }) {
|
||||
return "invalid copy"
|
||||
}
|
||||
val width = document.presentation.navigationRailExpandedWidthDp
|
||||
val shift = document.presentation.navigationContentShiftDp
|
||||
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
|
||||
return "unsafe presentation values"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private data class SemanticVersion(val major: Int, val minor: Int, val patch: Int) :
|
||||
Comparable<SemanticVersion> {
|
||||
override fun compareTo(other: SemanticVersion): Int =
|
||||
compareValuesBy(this, other, SemanticVersion::major, SemanticVersion::minor, SemanticVersion::patch)
|
||||
}
|
||||
|
||||
private fun semanticVersion(raw: String): SemanticVersion? {
|
||||
val parts = raw.split('.')
|
||||
if (parts.size != 3) return null
|
||||
val numbers = parts.map { it.toIntOrNull() ?: return null }
|
||||
if (numbers.any { it < 0 }) return null
|
||||
return SemanticVersion(numbers[0], numbers[1], numbers[2])
|
||||
}
|
||||
@@ -140,6 +140,9 @@ import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -236,6 +239,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
|
||||
@Composable
|
||||
fun TvNavigationRail(
|
||||
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
|
||||
selected: BrowseDestination,
|
||||
focusDestination: BrowseDestination = selected,
|
||||
expanded: Boolean,
|
||||
@@ -269,7 +273,7 @@ fun TvNavigationRail(
|
||||
}
|
||||
}
|
||||
val railWidth = animateDpAsState(
|
||||
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
|
||||
targetValue = if (expanded) config.expandedWidthDp.dp else TvRailCollapsedWidth,
|
||||
animationSpec = tween(160),
|
||||
label = "navigation-rail-width",
|
||||
)
|
||||
@@ -339,7 +343,7 @@ fun TvNavigationRail(
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
"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,6 +393,7 @@ fun TvNavigationRail(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (config.showVersion) {
|
||||
Text(
|
||||
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
|
||||
color = QuietText,
|
||||
@@ -399,6 +404,22 @@ fun TvNavigationRail(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configuredNavigationLabel(
|
||||
destination: BrowseDestination,
|
||||
labels: NavigationLabels,
|
||||
): String = when (destination) {
|
||||
BrowseDestination.HOME -> labels.home
|
||||
BrowseDestination.FOR_YOU -> labels.forYou
|
||||
BrowseDestination.SEARCH -> labels.search
|
||||
BrowseDestination.MOVIES -> labels.movies
|
||||
BrowseDestination.SHOWS -> labels.tvShows
|
||||
BrowseDestination.CALENDAR -> labels.tvCalendar
|
||||
BrowseDestination.FAVORITES -> labels.favourites
|
||||
BrowseDestination.PROFILES -> labels.user
|
||||
BrowseDestination.SETTINGS -> labels.settings
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -389,14 +389,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
||||
coroutineScope {
|
||||
// Resolution is tiny compared with video buffering and makes the later
|
||||
// Play click a memory lookup. The repository single-flights requests,
|
||||
// so focus and click can never duplicate the gateway call.
|
||||
if (item.membyPlayable) {
|
||||
// The row owns live UserData. Prefetching the metadata-cache copy used
|
||||
// to turn a 5% resume point into zero after details had been visited.
|
||||
launch { runCatching { repository.prefetchPlayable(focused) } }
|
||||
}
|
||||
// Do not negotiate playback on focus. Emby creates a playback session as
|
||||
// part of PlaybackInfo, so warming a stream here made merely browsing a
|
||||
// shelf appear in server history as something the viewer had played.
|
||||
// Warm the explanation and franchise siblings while the card is already
|
||||
// focused, so opening Details does not add a reason line a frame later.
|
||||
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
|
||||
@@ -423,7 +418,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
* The two requests a detail page still opened cold, warmed while the card is focused.
|
||||
*
|
||||
* Everything else the page needs is already in hand by the time it opens — the item
|
||||
* record, its "why you might enjoy it" and its playable URL are all warmed above — but
|
||||
* record and its "why you might enjoy it" are warmed above — but
|
||||
* the episode list and the trailer were not, so a series page opened with an empty
|
||||
* Episodes pane, no progress, no next episode and no estimated finish, and every page
|
||||
* opened with its trailer button missing until the network answered. Continue Watching
|
||||
|
||||
@@ -131,6 +131,7 @@ import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import com.ponzischeme89.memby.data.model.RecommendationPerson
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
|
||||
import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
|
||||
@@ -246,8 +247,11 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Captured before composition and never observed as state: a downloaded document
|
||||
// is for the next process, never a label change under the viewer's focus.
|
||||
val remoteConfig = ServiceLocator.remoteConfig.active
|
||||
setContent {
|
||||
MembyTheme { AppRoot(onCloseSettings = ::finish) }
|
||||
MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) }
|
||||
}
|
||||
PerformanceMonitor.start(this)
|
||||
}
|
||||
@@ -298,7 +302,7 @@ private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4
|
||||
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) {
|
||||
val repo = ServiceLocator.repository
|
||||
val context = LocalContext.current
|
||||
// This client intentionally has no token provider and no dependency on the active
|
||||
@@ -643,7 +647,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
|
||||
}
|
||||
key(loaded.userId, loaded.serverUrl) {
|
||||
HomeScreen(settings = loaded)
|
||||
HomeScreen(settings = loaded, remoteConfig = remoteConfig)
|
||||
}
|
||||
// Snow, bats or blossom for the few days a year a season is on, over the
|
||||
// launcher and nowhere else. Not over playback — a film is the one thing
|
||||
@@ -1887,6 +1891,7 @@ private fun ProfileTile(
|
||||
@Composable
|
||||
private fun HomeScreen(
|
||||
settings: Settings,
|
||||
remoteConfig: MembyRemoteConfig,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val context = LocalContext.current
|
||||
@@ -2347,13 +2352,14 @@ private fun HomeScreen(
|
||||
}
|
||||
|
||||
val contentShift by animateDpAsState(
|
||||
targetValue = if (navigationExpanded) TvRailContentShift else 0.dp,
|
||||
targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp,
|
||||
animationSpec = tween(150),
|
||||
label = "navigation-content-shift",
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
TvNavigationRail(
|
||||
config = remoteConfig.navigation,
|
||||
selected = selectedDestination,
|
||||
focusDestination = railFocusDestination,
|
||||
expanded = navigationExpanded,
|
||||
|
||||
@@ -316,7 +316,7 @@ internal fun MyShowDetailsOverlay(
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
StatusLine("Sonarr", show.sonarrStatus)
|
||||
StatusLine("Series", show.sonarrStatus)
|
||||
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
|
||||
StatusLine("Series status", show.lifecycle)
|
||||
Row(
|
||||
|
||||
@@ -156,7 +156,7 @@ internal fun CalendarContent(
|
||||
state.errorMessage.orEmpty(), "Try again", onRetry,
|
||||
)
|
||||
!state.calendar.available -> AgendaNotice(
|
||||
"The TV calendar is not available. It needs the Memby gateway with Sonarr configured.",
|
||||
"The TV calendar is not available. It needs the Memby gateway with a series service configured.",
|
||||
)
|
||||
week == null -> AgendaNotice("Nothing scheduled.")
|
||||
else -> {
|
||||
|
||||
@@ -1029,7 +1029,7 @@ private fun RequestOptions(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Search Radarr and Sonarr, then choose the exact movie or series you want added.",
|
||||
"Search for movies and series, then choose the exact title you want added.",
|
||||
color = Muted,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 2,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ponzischeme89.memby.data.remoteconfig
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class MembyRemoteConfigTest {
|
||||
@Test
|
||||
fun bundledDefaultsAreCompleteButNotARemoteRevision() {
|
||||
val bundled = BundledRemoteConfig.value
|
||||
|
||||
assertEquals(0, bundled.configVersion)
|
||||
assertEquals("Favourites", bundled.navigation.labels.favourites)
|
||||
assertEquals(184, bundled.navigation.expandedWidthDp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validCompatibleDocumentDecodes() {
|
||||
val document = decodeRemoteConfig(validDocument)!!
|
||||
|
||||
assertNull(validateRemoteConfig(document, "0.2.54"))
|
||||
assertEquals("Discover", document.navigation.labels.search)
|
||||
assertEquals(false, document.navigation.showVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incompatibleSchemaAndAppVersionsAreRejected() {
|
||||
val document = decodeRemoteConfig(validDocument)!!
|
||||
|
||||
assertNotNull(validateRemoteConfig(document.copy(schemaVersion = 2), "0.2.54"))
|
||||
assertNotNull(validateRemoteConfig(document, "0.2.53"))
|
||||
assertNotNull(
|
||||
validateRemoteConfig(
|
||||
document.copy(minimumAppVersion = "", maximumAppVersion = "0.2.53"),
|
||||
"0.2.54",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedCopyAndUnsafePresentationAreRejected() {
|
||||
val document = decodeRemoteConfig(validDocument)!!
|
||||
|
||||
assertNotNull(
|
||||
validateRemoteConfig(
|
||||
document.copy(copy = document.copy.copy(tagline = "\nNot safe")),
|
||||
"0.2.54",
|
||||
),
|
||||
)
|
||||
assertNotNull(
|
||||
validateRemoteConfig(
|
||||
document.copy(
|
||||
presentation = document.presentation.copy(
|
||||
navigationRailExpandedWidthDp = 500,
|
||||
),
|
||||
),
|
||||
"0.2.54",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val validDocument = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"configVersion": 7,
|
||||
"minimumAppVersion": "0.2.54",
|
||||
"copy": {
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"forYou": "For You",
|
||||
"search": "Discover",
|
||||
"movies": "Films",
|
||||
"tvShows": "TV Shows",
|
||||
"tvCalendar": "TV Calendar",
|
||||
"favourites": "Favourites",
|
||||
"user": "User",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"tagline": "Your library, made personal"
|
||||
},
|
||||
"features": { "showNavigationVersion": false },
|
||||
"presentation": {
|
||||
"navigationRailExpandedWidthDp": 196,
|
||||
"navigationContentShiftDp": 120
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
# health checks, maintenance polling and artwork requests.
|
||||
MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}"
|
||||
MEMBY_LOG_BUFFER_CAPACITY: "${MEMBY_LOG_BUFFER_CAPACITY:-5000}"
|
||||
# Structured history is restored into the admin log after a deployment.
|
||||
MEMBY_LOG_HISTORY_PATH: "${MEMBY_LOG_HISTORY_PATH:-/data/logs/events.jsonl}"
|
||||
# console is one aligned, readable line per event; logfmt and json are for tools.
|
||||
MEMBY_LOG_FORMAT: "${MEMBY_LOG_FORMAT:-console}"
|
||||
GOMEMLIMIT: "${MEMBY_GOMEMLIMIT:-384MiB}"
|
||||
@@ -29,6 +31,9 @@ services:
|
||||
MEMBY_REDIS_URL: "redis://redis:6379/0"
|
||||
MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}"
|
||||
MEMBY_RECOMMEND_TTL: "${MEMBY_RECOMMEND_TTL:-24h}"
|
||||
# Complete, schema-validated presentation document. Blank serves bundled-equivalent
|
||||
# defaults; downloaded changes are activated by TVs on their next app process.
|
||||
MEMBY_REMOTE_CONFIG_JSON: "${MEMBY_REMOTE_CONFIG_JSON:-}"
|
||||
# Strict per-Emby-user TV allowance. Signing the same physical TV in again
|
||||
# replaces its token and does not consume another slot.
|
||||
# Unset disables /admin entirely — the library import, maintenance switch and
|
||||
@@ -68,6 +73,7 @@ services:
|
||||
mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}"
|
||||
volumes:
|
||||
- memby-releases:/data/releases
|
||||
- memby-logs:/data/logs
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -110,3 +116,4 @@ services:
|
||||
volumes:
|
||||
memby-postgres:
|
||||
memby-releases:
|
||||
memby-logs:
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN mkdir -p /out/releases
|
||||
RUN mkdir -p /out/releases /out/logs
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
|
||||
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
|
||||
|
||||
@@ -18,6 +18,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/memby-server /app/memby-server
|
||||
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
|
||||
COPY --from=build --chown=nonroot:nonroot /out/logs /data/logs
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app/memby-server"]
|
||||
|
||||
+7
-1
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -53,8 +53,8 @@ Admin.onStatus((status) => {
|
||||
const mdblist = status.mdblist || {};
|
||||
const forYou = status.forYou || {};
|
||||
$('overview-services').innerHTML =
|
||||
row('Radarr', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('Sonarr', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('Movies', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('Series', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('MDBList ratings', mdblist.enabled
|
||||
? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok')
|
||||
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) +
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="inbox" data-icon-tone="info">Where a request goes</h2>
|
||||
<p class="card-note">A film is added to Radarr and a show to Sonarr, both unmonitored.
|
||||
No download search starts on its own.</p>
|
||||
<p class="card-note">A movie or series is monitored and searched for immediately.
|
||||
The configured download service handles it from there.</p>
|
||||
</div>
|
||||
<span class="row tight" id="request-services"></span>
|
||||
</div>
|
||||
|
||||
@@ -2,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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")), "/"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -90,6 +94,18 @@ type Buffer struct {
|
||||
events []Event
|
||||
start int
|
||||
next atomic.Int64
|
||||
history *historyFile
|
||||
}
|
||||
|
||||
// historyFile is an append-only JSONL archive of the same structured events the admin
|
||||
// console reads. It is compacted to the ring's retained tail at startup and after each
|
||||
// further ringful, so persistence cannot become an unbounded disk cost.
|
||||
type historyFile struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
file *os.File
|
||||
capacity int
|
||||
lastCompacted int64
|
||||
}
|
||||
|
||||
// ParseCapacity returns a non-negative log buffer capacity from configuration.
|
||||
@@ -131,6 +147,69 @@ func NewBuffered(
|
||||
return slog.New(&captureHandler{next: written, buffer: buffer, level: level}), buffer
|
||||
}
|
||||
|
||||
// NewPersistentBuffered restores retained events from path before accepting new ones and
|
||||
// appends every subsequent accepted record. The caller should close the returned Buffer.
|
||||
// An empty path keeps the in-memory behaviour used by unit tests and small embeddings.
|
||||
func NewPersistentBuffered(
|
||||
w io.Writer, level slog.Leveler, capacity int, format Format, path string,
|
||||
) (*slog.Logger, *Buffer, error) {
|
||||
logger, buffer := NewBuffered(w, level, capacity, format)
|
||||
path = strings.TrimSpace(path)
|
||||
if capacity <= 0 || path == "" {
|
||||
return logger, buffer, nil
|
||||
}
|
||||
history, restored, err := openHistory(path, capacity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
buffer.history = history
|
||||
buffer.events = restored
|
||||
if len(restored) > 0 {
|
||||
buffer.next.Store(restored[len(restored)-1].Sequence)
|
||||
}
|
||||
return logger, buffer, nil
|
||||
}
|
||||
|
||||
func openHistory(path string, capacity int) (*historyFile, []Event, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
input, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0o640)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
restored := make([]Event, 0, capacity)
|
||||
scanner := bufio.NewScanner(input)
|
||||
// Error attributes are capped upstream, but allow headroom for structured records.
|
||||
scanner.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for scanner.Scan() {
|
||||
var event Event
|
||||
if json.Unmarshal(scanner.Bytes(), &event) != nil || event.Sequence <= 0 {
|
||||
continue
|
||||
}
|
||||
restored = append(restored, event)
|
||||
if len(restored) > capacity {
|
||||
copy(restored, restored[len(restored)-capacity:])
|
||||
restored = restored[:capacity]
|
||||
}
|
||||
}
|
||||
closeErr := input.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, nil, closeErr
|
||||
}
|
||||
history := &historyFile{path: path, capacity: capacity}
|
||||
if len(restored) > 0 {
|
||||
history.lastCompacted = restored[len(restored)-1].Sequence
|
||||
}
|
||||
if err := history.rewrite(restored); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return history, restored, nil
|
||||
}
|
||||
|
||||
type captureHandler struct {
|
||||
next slog.Handler
|
||||
buffer *Buffer
|
||||
@@ -197,9 +276,77 @@ func (b *Buffer) append(event Event) {
|
||||
if len(b.events) == b.capacity {
|
||||
b.events[b.start] = event
|
||||
b.start = (b.start + 1) % b.capacity
|
||||
return
|
||||
}
|
||||
} else {
|
||||
b.events = append(b.events, event)
|
||||
}
|
||||
if b.history != nil {
|
||||
ordered := b.orderedEventsLocked()
|
||||
_ = b.history.append(event, ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) orderedEventsLocked() []Event {
|
||||
ordered := make([]Event, len(b.events))
|
||||
for i := range b.events {
|
||||
ordered[i] = b.events[(b.start+i)%len(b.events)]
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (h *historyFile) append(event Event, retained []Event) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if event.Sequence-h.lastCompacted >= int64(h.capacity) {
|
||||
if err := h.rewriteLocked(retained); err != nil {
|
||||
return err
|
||||
}
|
||||
h.lastCompacted = event.Sequence
|
||||
return nil
|
||||
}
|
||||
return json.NewEncoder(h.file).Encode(event)
|
||||
}
|
||||
|
||||
func (h *historyFile) rewrite(events []Event) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.rewriteLocked(events)
|
||||
}
|
||||
|
||||
func (h *historyFile) rewriteLocked(events []Event) error {
|
||||
if h.file != nil {
|
||||
if err := h.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, event := range events {
|
||||
if err := encoder.Encode(event); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.file, err = os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close flushes the persistent archive. It is safe for an in-memory Buffer.
|
||||
func (b *Buffer) Close() error {
|
||||
if b == nil || b.history == nil {
|
||||
return nil
|
||||
}
|
||||
b.history.mu.Lock()
|
||||
defer b.history.mu.Unlock()
|
||||
if b.history.file == nil {
|
||||
return nil
|
||||
}
|
||||
return b.history.file.Close()
|
||||
}
|
||||
|
||||
// Events returns records strictly newer than after, up to limit. If the caller fell
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user