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