Server changes/Sonarr
This commit is contained in:
@@ -14,14 +14,28 @@ POSTGRES_PASSWORD=change-me
|
||||
# Host port the gateway listens on.
|
||||
MEMBY_PORT=8080
|
||||
|
||||
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
|
||||
MEMBY_LOG_LEVEL=INFO
|
||||
|
||||
# Used for "today" boundaries and human-readable Sonarr air/download times.
|
||||
MEMBY_TIMEZONE=Pacific/Auckland
|
||||
|
||||
# How long a cached home payload stays warm.
|
||||
MEMBY_HOME_TTL=60s
|
||||
|
||||
# Maximum number of distinct Memby TVs one Emby user may keep signed in.
|
||||
MEMBY_MAX_CLIENTS_PER_USER=1
|
||||
|
||||
# Admin interface at http://<host>:8080/admin/ — library imports, the maintenance
|
||||
# switch, and row analytics. Leave blank to disable /admin entirely. Generate with
|
||||
# openssl rand -hex 32
|
||||
MEMBY_ADMIN_TOKEN=
|
||||
|
||||
# Public gateway address and a dedicated token used only by the Gitea release workflow.
|
||||
# Generate the token with: openssl rand -hex 32
|
||||
MEMBY_PUBLIC_URL=https://mserver.sublogue.com
|
||||
MEMBY_RELEASE_PUBLISH_TOKEN=
|
||||
|
||||
# Library import. Hourly incremental keeps up with episodes added through the day.
|
||||
MEMBY_SYNC_INTERVAL=1h
|
||||
MEMBY_SYNC_ON_START=false
|
||||
@@ -30,3 +44,10 @@ MEMBY_SYNC_ON_START=false
|
||||
# recently active TV session, which works but stops if that user is removed.
|
||||
#MEMBY_SYNC_USER_ID=
|
||||
#MEMBY_SYNC_API_KEY=
|
||||
|
||||
# Optional Sonarr calendar integration. Use the URL reachable from this container,
|
||||
# not necessarily the address entered in a browser. The API key is in Sonarr under
|
||||
# Settings > General > Security.
|
||||
#MEMBY_SONARR_URL=http://192.168.20.2:8989
|
||||
#MEMBY_SONARR_API_KEY=
|
||||
MEMBY_SONARR_TTL=5m
|
||||
|
||||
+16
-5
@@ -15,6 +15,18 @@ val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?)
|
||||
// becomes a thin renderer; blank keeps the direct-to-Emby path above.
|
||||
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.1.54"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
?.takeIf { it.matches(Regex("""\d+\.\d+\.\d+""")) }
|
||||
?: defaultVersionName
|
||||
val membyVersionParts = membyVersionName.split('.').map(String::toInt)
|
||||
val membyVersionCode =
|
||||
membyVersionParts[0] * 10_000 + membyVersionParts[1] * 100 + membyVersionParts[2]
|
||||
|
||||
/** Reads a key from local.properties, which is gitignored and holds machine secrets. */
|
||||
val localProperties = Properties().apply {
|
||||
val file = rootProject.file("local.properties")
|
||||
@@ -37,11 +49,10 @@ android {
|
||||
applicationId = "com.ponzischeme89.memby"
|
||||
minSdk = 23
|
||||
targetSdk = 35
|
||||
// versionCode is derived from versionName: major*10000 + minor*100 + patch.
|
||||
// 0.1.53 -> 153. Keep them in step; the in-app updater compares versionName,
|
||||
// but Android will not install an APK whose versionCode went backwards.
|
||||
versionCode = 153
|
||||
versionName = "0.1.53"
|
||||
// Derived from one version string so CI cannot publish a versionName/versionCode
|
||||
// pair that Android later refuses to install.
|
||||
versionCode = membyVersionCode
|
||||
versionName = membyVersionName
|
||||
|
||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby
|
||||
|
||||
import android.content.Context
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||
import com.ponzischeme89.memby.data.SettingsStore
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,13 @@ object ServiceLocator {
|
||||
private set
|
||||
lateinit var repository: EmbyRepository
|
||||
private set
|
||||
lateinit var maintenance: MaintenanceMonitor
|
||||
private set
|
||||
|
||||
fun init(context: Context) {
|
||||
if (::repository.isInitialized) return
|
||||
settings = SettingsStore(context.applicationContext)
|
||||
repository = EmbyRepository(settings)
|
||||
maintenance = MaintenanceMonitor(repository)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package com.ponzischeme89.memby.data
|
||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthError
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
||||
@@ -15,12 +18,17 @@ import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
||||
import com.ponzischeme89.memby.data.remote.GatewayApi
|
||||
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.HttpException
|
||||
@@ -47,6 +55,7 @@ data class Playable(
|
||||
val title: String,
|
||||
val url: String,
|
||||
val resumePositionMs: Long = 0L,
|
||||
val logoUrl: String? = null,
|
||||
)
|
||||
|
||||
class EmbyRepository(private val settings: SettingsStore) {
|
||||
@@ -54,15 +63,20 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile
|
||||
private var snapshot: Settings = Settings.EMPTY
|
||||
private var observedSettings: Settings = Settings.EMPTY
|
||||
private val snapshot: Settings
|
||||
get() = settings.current ?: observedSettings
|
||||
|
||||
init {
|
||||
scope.launch { settings.settingsFlow.collect { snapshot = it } }
|
||||
scope.launch { settings.settingsFlow.collect { observedSettings = it } }
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
|
||||
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val playbackStops = _playbackStops.asSharedFlow()
|
||||
private val playableMutex = Mutex()
|
||||
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
|
||||
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
|
||||
|
||||
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||
|
||||
@@ -119,20 +133,29 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
* Signs in. [serverUrl] is only consulted when the build does not hardwire one —
|
||||
* with a hardwired address the setup screen never collects it.
|
||||
*/
|
||||
suspend fun authenticate(serverUrl: String, username: String, password: String) {
|
||||
suspend fun authenticate(serverUrl: String, username: String, password: String, deviceName: String) {
|
||||
clearPlayableCache()
|
||||
settings.ensureDeviceId()
|
||||
snapshot = settings.snapshot() // pick up the freshly-generated device id
|
||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||
|
||||
ServerConfig.gatewayUrl?.let { gateway ->
|
||||
// The gateway holds the Emby token; this device only ever stores the gateway
|
||||
// token, so the same `token` slot in DataStore serves both modes.
|
||||
val result = requireGateway().login(
|
||||
GatewayLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
deviceId = snapshot.deviceId.ifEmpty { "memby" },
|
||||
),
|
||||
)
|
||||
val result = try {
|
||||
requireGateway().login(
|
||||
GatewayLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
deviceId = snapshot.deviceId.ifEmpty { "memby" },
|
||||
deviceName = deviceName.trim(),
|
||||
),
|
||||
)
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() == 409) {
|
||||
parseDeviceLimit(error.response()?.errorBody()?.string().orEmpty())?.let { throw it }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
|
||||
"Gateway did not return a session"
|
||||
}
|
||||
@@ -143,7 +166,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
result.username.ifBlank { username },
|
||||
result.serverId.takeIf { it.isNotBlank() },
|
||||
)
|
||||
snapshot = settings.snapshot()
|
||||
settings.setDeviceName(deviceName)
|
||||
observedSettings = settings.snapshot()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -157,9 +181,13 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
"Server did not return an access token"
|
||||
}
|
||||
settings.saveSession(base, token, userId, username, result.serverId)
|
||||
snapshot = settings.snapshot()
|
||||
settings.setDeviceName(deviceName)
|
||||
observedSettings = settings.snapshot()
|
||||
}
|
||||
|
||||
suspend fun authPolicy(): GatewayAuthPolicy? =
|
||||
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
|
||||
|
||||
suspend fun signOut() {
|
||||
// Retire the gateway token server-side too, so a lost TV can't keep reading the
|
||||
// library. A failure here must not block the local sign-out.
|
||||
@@ -167,16 +195,18 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
runCatching { requireGateway().logout() }
|
||||
}
|
||||
settings.clearSession()
|
||||
snapshot = settings.snapshot()
|
||||
observedSettings = settings.snapshot()
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
}
|
||||
|
||||
suspend fun switchProfile(profile: EmbyProfile) {
|
||||
settings.switchProfile(profile)
|
||||
snapshot = settings.snapshot()
|
||||
observedSettings = settings.snapshot()
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
}
|
||||
|
||||
// --- Content -------------------------------------------------------------
|
||||
@@ -343,6 +373,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
?.takeIf { it.isActionable }
|
||||
}
|
||||
|
||||
/** Live gateway state. Unlike content routes, this remains available in maintenance. */
|
||||
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
|
||||
|
||||
/** Full item metadata, requested only after focus settles on an item. */
|
||||
suspend fun getItemDetails(itemId: String): BaseItem {
|
||||
if (ServerConfig.isGateway) return requireGateway().item(itemId)
|
||||
@@ -445,14 +478,67 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
* for a series we play the next-up episode (falling back to the first one).
|
||||
*/
|
||||
suspend fun resolvePlayable(item: BaseItem): Playable {
|
||||
require(item.membyPlayable) { "This item is informational and cannot be played" }
|
||||
val now = System.currentTimeMillis()
|
||||
val (cached, request) = playableMutex.withLock {
|
||||
val ready = playableCache[item.id]?.takeIf { it.expiresAtMs > now }?.playable
|
||||
if (ready != null) {
|
||||
ready to null
|
||||
} else {
|
||||
playableCache.remove(item.id)
|
||||
null to (playableInFlight[item.id] ?: newPlayableRequest(item))
|
||||
}
|
||||
}
|
||||
return cached ?: requireNotNull(request).await()
|
||||
}
|
||||
|
||||
/** Resolves the likely stream after focus settles, without opening or buffering it. */
|
||||
suspend fun prefetchPlayable(item: BaseItem) {
|
||||
if (!item.membyPlayable) return
|
||||
resolvePlayable(item)
|
||||
}
|
||||
|
||||
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
|
||||
val request = scope.async(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
resolvePlayableUncached(item).also { playable ->
|
||||
playableMutex.withLock {
|
||||
playableCache[item.id] = CachedPlayable(
|
||||
playable = playable,
|
||||
expiresAtMs = System.currentTimeMillis() + PLAYABLE_CACHE_TTL_MS,
|
||||
)
|
||||
while (playableCache.size > PLAYABLE_CACHE_SIZE) {
|
||||
playableCache.entries.iterator().run {
|
||||
next()
|
||||
remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
playableMutex.withLock { playableInFlight.remove(item.id) }
|
||||
}
|
||||
}
|
||||
playableInFlight[item.id] = request
|
||||
request.start()
|
||||
return request
|
||||
}
|
||||
|
||||
private suspend fun resolvePlayableUncached(item: BaseItem): Playable {
|
||||
if (ServerConfig.isGateway) {
|
||||
// Episode selection for a series is the gateway's job now.
|
||||
val playback = requireGateway().playback(item.id)
|
||||
val playback = requireGateway().playback(
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
title = item.name,
|
||||
resumePositionMs = item.resumePositionMs,
|
||||
)
|
||||
return Playable(
|
||||
itemId = playback.itemId,
|
||||
title = playback.title.ifBlank { item.name },
|
||||
url = playback.url,
|
||||
resumePositionMs = playback.resumePositionMs,
|
||||
logoUrl = logoUrl(item),
|
||||
)
|
||||
}
|
||||
if (item.isSeries) {
|
||||
@@ -463,9 +549,29 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
append(item.name)
|
||||
episode.name.takeIf { it.isNotBlank() }?.let { append(" – $it") }
|
||||
}
|
||||
return Playable(episode.id, title, buildStreamUrl(episode.id), episode.resumePositionMs)
|
||||
return Playable(
|
||||
episode.id,
|
||||
title,
|
||||
buildStreamUrl(episode.id),
|
||||
episode.resumePositionMs,
|
||||
logoUrl(item),
|
||||
)
|
||||
}
|
||||
return Playable(
|
||||
item.id,
|
||||
item.name,
|
||||
buildStreamUrl(item.id),
|
||||
item.resumePositionMs,
|
||||
logoUrl(item),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun clearPlayableCache() {
|
||||
playableMutex.withLock {
|
||||
playableCache.clear()
|
||||
playableInFlight.values.forEach { it.cancel() }
|
||||
playableInFlight.clear()
|
||||
}
|
||||
return Playable(item.id, item.name, buildStreamUrl(item.id), item.resumePositionMs)
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) {
|
||||
@@ -494,6 +600,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
|
||||
}
|
||||
} finally {
|
||||
clearPlayableCache()
|
||||
_playbackStops.tryEmit(itemId)
|
||||
}
|
||||
}
|
||||
@@ -628,12 +735,41 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||
}
|
||||
|
||||
private data class CachedPlayable(
|
||||
val playable: Playable,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private const val PLAYABLE_CACHE_SIZE = 16
|
||||
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||
|
||||
internal fun millisecondsToTicks(milliseconds: Long): Long =
|
||||
milliseconds.coerceAtLeast(0L) * 10_000L
|
||||
|
||||
private val BaseItem.resumePositionMs: Long
|
||||
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
|
||||
|
||||
class DeviceLimitException(
|
||||
val activeClients: Int,
|
||||
val maxClients: Int,
|
||||
) : Exception("Memby device allowance reached")
|
||||
|
||||
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
|
||||
if (body.isBlank()) return null
|
||||
return runCatching {
|
||||
val parsed = Json { ignoreUnknownKeys = true }
|
||||
.decodeFromString<GatewayAuthError>(body)
|
||||
parsed.takeIf {
|
||||
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
|
||||
}?.let {
|
||||
DeviceLimitException(
|
||||
activeClients = it.activeClients.coerceAtLeast(0),
|
||||
maxClients = it.maxClientsPerUser,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
|
||||
* bodies or stack traces (which could contain tokens) to the screen.
|
||||
|
||||
@@ -10,8 +10,15 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -28,6 +35,7 @@ data class Settings(
|
||||
val serverId: String? = null,
|
||||
val username: String? = null,
|
||||
val deviceId: String = "",
|
||||
val deviceName: String = "",
|
||||
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
|
||||
// Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access
|
||||
// token for the (private) repo's release API and asset downloads.
|
||||
@@ -75,6 +83,12 @@ data class EmbyProfile(
|
||||
)
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@Volatile
|
||||
private var latestSettings: Settings? = null
|
||||
|
||||
val current: Settings?
|
||||
get() = latestSettings
|
||||
|
||||
private object Keys {
|
||||
val SERVER_URL = stringPreferencesKey("server_url")
|
||||
@@ -83,6 +97,7 @@ class SettingsStore(private val context: Context) {
|
||||
val SERVER_ID = stringPreferencesKey("server_id")
|
||||
val USERNAME = stringPreferencesKey("username")
|
||||
val DEVICE_ID = stringPreferencesKey("device_id")
|
||||
val DEVICE_NAME = stringPreferencesKey("device_name")
|
||||
val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds")
|
||||
val UPDATE_BASE_URL = stringPreferencesKey("update_base_url")
|
||||
val UPDATE_REPO = stringPreferencesKey("update_repo")
|
||||
@@ -97,39 +112,26 @@ class SettingsStore(private val context: Context) {
|
||||
val PROFILES = stringPreferencesKey("profiles")
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<Settings> = context.dataStore.data.map { p ->
|
||||
val storedProfiles = decodeProfiles(p[Keys.PROFILES])
|
||||
val profiles = if (storedProfiles.isEmpty()) {
|
||||
legacyProfile(p)?.let(::listOf).orEmpty()
|
||||
} else {
|
||||
storedProfiles
|
||||
}
|
||||
Settings(
|
||||
serverUrl = p[Keys.SERVER_URL],
|
||||
token = p[Keys.TOKEN],
|
||||
userId = p[Keys.USER_ID],
|
||||
serverId = p[Keys.SERVER_ID],
|
||||
username = p[Keys.USERNAME],
|
||||
deviceId = p[Keys.DEVICE_ID].orEmpty(),
|
||||
rotationIntervalSeconds = p[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
|
||||
updateBaseUrl = p[Keys.UPDATE_BASE_URL],
|
||||
updateRepo = p[Keys.UPDATE_REPO],
|
||||
updateToken = p[Keys.UPDATE_TOKEN],
|
||||
showTitleLogo = p[Keys.SHOW_TITLE_LOGO] ?: true,
|
||||
ringColorHex = p[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
||||
lastBackdropUrl = p[Keys.LAST_BACKDROP_URL],
|
||||
homeSections = p[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCacheJson = p[Keys.HOME_CACHE],
|
||||
homeCardDensity = p[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
showHomeCardMetadata = p[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
profiles = profiles,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Start the single disk read as soon as the Application creates this store, then
|
||||
* replay the decoded value to activities, repositories and services. Previously
|
||||
* every collector repeated the cold mapping work and the home screen could be
|
||||
* created before the repository had seen the saved cache.
|
||||
*/
|
||||
val settingsFlow: Flow<Settings> = context.dataStore.data
|
||||
.map(::settingsFrom)
|
||||
.distinctUntilChanged()
|
||||
.onEach { latestSettings = it }
|
||||
.shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
suspend fun setRotationIntervalSeconds(seconds: Int) {
|
||||
context.dataStore.edit { it[Keys.ROTATION_SECONDS] = seconds }
|
||||
}
|
||||
|
||||
suspend fun setDeviceName(name: String) {
|
||||
context.dataStore.edit { it[Keys.DEVICE_NAME] = name.trim().take(80) }
|
||||
}
|
||||
|
||||
/** Persists the Gitea update source. Blank values are cleared. */
|
||||
suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) {
|
||||
context.dataStore.edit {
|
||||
@@ -196,8 +198,12 @@ class SettingsStore(private val context: Context) {
|
||||
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
|
||||
}
|
||||
|
||||
/** Reads a one-shot snapshot of the current settings. */
|
||||
suspend fun snapshot(): Settings = settingsFlow.first()
|
||||
/**
|
||||
* Reads DataStore directly instead of taking the replayed flow value. This matters
|
||||
* immediately after an edit, when the replay slot may still contain the prior value.
|
||||
*/
|
||||
suspend fun snapshot(): Settings = settingsFrom(context.dataStore.data.first())
|
||||
.also { latestSettings = it }
|
||||
|
||||
/** Returns the stable device id, generating and persisting one on first use. */
|
||||
suspend fun ensureDeviceId(): String {
|
||||
@@ -284,6 +290,36 @@ class SettingsStore(private val context: Context) {
|
||||
private fun decodeProfiles(value: String?): List<EmbyProfile> =
|
||||
value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty()
|
||||
|
||||
private fun settingsFrom(preferences: Preferences): Settings {
|
||||
val storedProfiles = decodeProfiles(preferences[Keys.PROFILES])
|
||||
val profiles = if (storedProfiles.isEmpty()) {
|
||||
legacyProfile(preferences)?.let(::listOf).orEmpty()
|
||||
} else {
|
||||
storedProfiles
|
||||
}
|
||||
return Settings(
|
||||
serverUrl = preferences[Keys.SERVER_URL],
|
||||
token = preferences[Keys.TOKEN],
|
||||
userId = preferences[Keys.USER_ID],
|
||||
serverId = preferences[Keys.SERVER_ID],
|
||||
username = preferences[Keys.USERNAME],
|
||||
deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
|
||||
deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
|
||||
rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
|
||||
updateBaseUrl = preferences[Keys.UPDATE_BASE_URL],
|
||||
updateRepo = preferences[Keys.UPDATE_REPO],
|
||||
updateToken = preferences[Keys.UPDATE_TOKEN],
|
||||
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
||||
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
||||
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
||||
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
profiles = profiles,
|
||||
)
|
||||
}
|
||||
|
||||
private fun profilesFrom(preferences: Preferences): List<EmbyProfile> =
|
||||
decodeProfiles(preferences[Keys.PROFILES]).ifEmpty {
|
||||
legacyProfile(preferences)?.let(::listOf).orEmpty()
|
||||
|
||||
@@ -86,11 +86,22 @@ data class BaseItem(
|
||||
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
|
||||
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
|
||||
@SerialName("UserData") val userData: UserItemData? = null,
|
||||
// Server-authored schedule metadata. These fields are absent on normal Emby items.
|
||||
@SerialName("MembySource") val membySource: String? = null,
|
||||
@SerialName("MembyEpisodeTitle") val membyEpisodeTitle: String? = null,
|
||||
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
|
||||
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
|
||||
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
|
||||
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
|
||||
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
||||
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
||||
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
|
||||
) {
|
||||
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
|
||||
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
|
||||
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
|
||||
val isFavorite: Boolean get() = userData?.isFavorite == true
|
||||
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
|
||||
|
||||
/** Runtime in whole minutes, or null when unknown. */
|
||||
val runtimeMinutes: Int?
|
||||
|
||||
@@ -15,6 +15,7 @@ data class GatewayLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -23,6 +24,21 @@ data class GatewayLoginResponse(
|
||||
val userId: String = "",
|
||||
val username: String = "",
|
||||
val serverId: String = "",
|
||||
val activeClients: Int = 0,
|
||||
val maxClientsPerUser: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayAuthPolicy(
|
||||
val maxClientsPerUser: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayAuthError(
|
||||
val error: String = "",
|
||||
val message: String = "",
|
||||
val activeClients: Int = 0,
|
||||
val maxClientsPerUser: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -83,6 +99,13 @@ data class GatewayUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
/** Lightweight live state returned even while normal gateway routes are in maintenance. */
|
||||
@Serializable
|
||||
data class GatewayServiceStatus(
|
||||
val maintenance: Boolean = false,
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
/** Response of `GET /v1/recommendations`. */
|
||||
@Serializable
|
||||
data class GatewayRows(
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayItems
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||
@@ -10,6 +11,7 @@ import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayRows
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import retrofit2.http.Body
|
||||
@@ -30,6 +32,9 @@ interface GatewayApi {
|
||||
@POST("v1/auth/login")
|
||||
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
|
||||
|
||||
@GET("v1/auth/policy")
|
||||
suspend fun authPolicy(): GatewayAuthPolicy
|
||||
|
||||
@POST("v1/auth/logout")
|
||||
suspend fun logout()
|
||||
|
||||
@@ -53,11 +58,20 @@ interface GatewayApi {
|
||||
@GET("v1/update")
|
||||
suspend fun updateStatus(): GatewayUpdate
|
||||
|
||||
/** Available during maintenance so an open app can be interrupted immediately. */
|
||||
@GET("v1/status")
|
||||
suspend fun serviceStatus(): GatewayServiceStatus
|
||||
|
||||
@GET("v1/items/{id}")
|
||||
suspend fun item(@Path("id") itemId: String): BaseItem
|
||||
|
||||
@GET("v1/items/{id}/playback")
|
||||
suspend fun playback(@Path("id") itemId: String): GatewayPlayback
|
||||
suspend fun playback(
|
||||
@Path("id") itemId: String,
|
||||
@Query("type") itemType: String,
|
||||
@Query("title") title: String,
|
||||
@Query("resumePositionMs") resumePositionMs: Long,
|
||||
): GatewayPlayback
|
||||
|
||||
@GET("v1/items/{id}/trailer")
|
||||
suspend fun trailer(@Path("id") itemId: String): BaseItem
|
||||
|
||||
@@ -69,16 +69,25 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.LiveTv
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||
import androidx.compose.material.icons.filled.Recommend
|
||||
import androidx.compose.material.icons.filled.SentimentVerySatisfied
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.filled.Tv
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Text
|
||||
@@ -120,6 +129,58 @@ data class HomeBrowseRow(
|
||||
val showSecondaryMetadata: Boolean = true,
|
||||
)
|
||||
|
||||
private data class HomeRowVisual(
|
||||
val icon: ImageVector,
|
||||
val colors: List<Color>,
|
||||
)
|
||||
|
||||
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
row.id == "continue" -> HomeRowVisual(
|
||||
Icons.Default.PlayCircleFilled,
|
||||
listOf(Color(0xFF3A7BD5), Color(0xFF6C4DFF)),
|
||||
)
|
||||
row.id == "next-up" -> HomeRowVisual(
|
||||
Icons.Default.SkipNext,
|
||||
listOf(Color(0xFF00A896), Color(0xFF28C2A0)),
|
||||
)
|
||||
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
|
||||
Icons.Default.Favorite,
|
||||
listOf(Color(0xFFFF5C8A), Color(0xFFB84DFF)),
|
||||
)
|
||||
row.id == "latest-movies" -> HomeRowVisual(
|
||||
Icons.Default.Movie,
|
||||
listOf(Color(0xFFFF8A3D), Color(0xFFE34B6F)),
|
||||
)
|
||||
row.id == "sonarr-airing-today" -> HomeRowVisual(
|
||||
Icons.Default.CalendarMonth,
|
||||
listOf(Color(0xFF00A9CE), Color(0xFF4169E1)),
|
||||
)
|
||||
row.id == "curated:apple-tv" -> HomeRowVisual(
|
||||
Icons.Default.LiveTv,
|
||||
listOf(Color(0xFF252A31), Color(0xFF66717E)),
|
||||
)
|
||||
row.id == "curated:drama-shows" -> HomeRowVisual(
|
||||
Icons.Default.TheaterComedy,
|
||||
listOf(Color(0xFF6A3FB5), Color(0xFFB64272)),
|
||||
)
|
||||
row.id == "curated:comedy-shows" -> HomeRowVisual(
|
||||
Icons.Default.SentimentVerySatisfied,
|
||||
listOf(Color(0xFFFFB52E), Color(0xFFFF7433)),
|
||||
)
|
||||
row.id.startsWith("similar:") -> HomeRowVisual(
|
||||
Icons.Default.AutoAwesome,
|
||||
listOf(Color(0xFF8A5CF6), Color(0xFFE458A3)),
|
||||
)
|
||||
row.id == "recommended" -> HomeRowVisual(
|
||||
Icons.Default.Recommend,
|
||||
listOf(Color(0xFF45B649), Color(0xFF00A896)),
|
||||
)
|
||||
else -> HomeRowVisual(
|
||||
Icons.Default.VideoLibrary,
|
||||
listOf(Color(0xFF536976), Color(0xFF738A96)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvNavigationRail(
|
||||
selected: BrowseDestination,
|
||||
@@ -160,11 +221,27 @@ fun TvNavigationRail(
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
contentDescription = "Emby",
|
||||
contentDescription = "Memby",
|
||||
modifier = Modifier.width(32.dp).height(27.dp),
|
||||
)
|
||||
if (expanded) {
|
||||
Text("Emby", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
"Memby",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
"Matt’s Android TV client",
|
||||
color = QuietText,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -528,6 +605,10 @@ fun MediaQuickActionsOverlay(
|
||||
|
||||
@Composable
|
||||
private fun MetadataContent(item: BaseItem, sectionLabel: String) {
|
||||
if (item.isSonarrSchedule) {
|
||||
ScheduleMetadataContent(item, sectionLabel)
|
||||
return
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
@@ -574,6 +655,62 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleMetadataContent(item: BaseItem, sectionLabel: String) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Text(
|
||||
sectionLabel.uppercase(),
|
||||
color = EmbyGreen,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.2.sp,
|
||||
)
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 34.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(
|
||||
item.membyEpisodeCode,
|
||||
item.membyEpisodeTitle?.takeIf(String::isNotBlank),
|
||||
).joinToString(" • "),
|
||||
color = MutedText,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item.membyAirLabel?.takeIf(String::isNotBlank)?.let { MediaBadge(it.uppercase()) }
|
||||
item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let {
|
||||
MediaBadge(it.uppercase())
|
||||
}
|
||||
}
|
||||
Text(
|
||||
item.overview?.takeIf(String::isNotBlank)
|
||||
?: "Episode information will appear when Sonarr receives it.",
|
||||
color = Color(0xFFD0D4D7),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(0.74f),
|
||||
)
|
||||
Text(
|
||||
"Schedule information from Sonarr",
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataStatus(item: BaseItem) {
|
||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||
@@ -656,6 +793,23 @@ fun MediaRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val visual = homeRowVisual(row)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brush.linearGradient(visual.colors))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = visual.icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
row.title,
|
||||
color = Color(0xFFF1F3F4),
|
||||
@@ -978,6 +1132,12 @@ private fun MediaCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.isSonarrSchedule) {
|
||||
ScheduleStatusBadge(
|
||||
status = item.membyAvailability.orEmpty(),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
item.name,
|
||||
@@ -1004,6 +1164,28 @@ private fun MediaCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
|
||||
val (label, color) = when (status) {
|
||||
"available" -> "ADDED" to EmbyGreen
|
||||
"downloading" -> "DOWNLOADING" to Color(0xFF5DA9FF)
|
||||
"awaiting" -> "AWAITING" to Color(0xFFFFB454)
|
||||
"unmonitored" -> "UNMONITORED" to QuietText
|
||||
else -> "TODAY" to Color(0xFFE1E5E8)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = if (status == "available") Color(0xFF071008) else Color(0xFF090B0D),
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.padding(horizontal = 7.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FocusScaleContainer(
|
||||
onFocused: () -> Unit,
|
||||
@@ -1127,6 +1309,7 @@ private const val BACKDROP_SETTLE_DELAY_MS = 240L
|
||||
|
||||
private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when {
|
||||
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
|
||||
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
|
||||
item.isEpisode -> item.seriesName ?: "Up next"
|
||||
item.productionYear != null && item.runtimeMinutes != null ->
|
||||
"${item.productionYear} • ${formatTvRuntime(requireNotNull(item.runtimeMinutes))}"
|
||||
@@ -1141,6 +1324,10 @@ private fun cardDescription(item: BaseItem, progress: Float): String = buildStri
|
||||
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
|
||||
if (item.userData?.played == true) append(", watched")
|
||||
if (item.isFavorite) append(", favourite")
|
||||
if (item.isSonarrSchedule) {
|
||||
item.membyAirLabel?.let { append(", ").append(it) }
|
||||
item.membyAvailabilityText?.let { append(", ").append(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTvRuntime(minutes: Int): String {
|
||||
|
||||
@@ -103,6 +103,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
init {
|
||||
refreshAll()
|
||||
checkForAppUpdate()
|
||||
viewModelScope.launch {
|
||||
// A TV can leave Memby open for days. Keep checking at a deliberately slow
|
||||
// cadence so a newly published release appears without requiring a restart.
|
||||
while (true) {
|
||||
delay(UPDATE_CHECK_INTERVAL_MS)
|
||||
checkForAppUpdate()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.playbackStops.collect { refreshWatching() }
|
||||
}
|
||||
@@ -224,13 +232,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
||||
_focusedItem.value = cached ?: item
|
||||
metadataJob?.cancel()
|
||||
if (cached != null) return
|
||||
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
||||
val details = runCatching { repository.getItemDetails(item.id) }.getOrNull() ?: return@launch
|
||||
synchronized(metadataCache) { metadataCache[item.id] = details }
|
||||
if (_focusedItem.value?.id == item.id) {
|
||||
_focusedItem.value = details
|
||||
coroutineScope {
|
||||
// Resolution is tiny compared with video buffering and makes the later
|
||||
// Play click a memory lookup. The repository single-flights requests,
|
||||
// so focus and click can never duplicate the gateway call.
|
||||
if (item.membyPlayable) {
|
||||
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
|
||||
}
|
||||
if (cached == null && !item.isSonarrSchedule) {
|
||||
launch {
|
||||
val details = runCatching {
|
||||
repository.getItemDetails(item.id)
|
||||
}.getOrNull() ?: return@launch
|
||||
synchronized(metadataCache) { metadataCache[item.id] = details }
|
||||
if (_focusedItem.value?.id == item.id) {
|
||||
_focusedItem.value = details
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,6 +402,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
companion object {
|
||||
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
|
||||
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
||||
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
|
||||
|
||||
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
||||
state.watchingAndNextUp.firstOrNull()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings as AndroidSettings
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -11,6 +15,7 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -45,6 +50,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
@@ -71,6 +77,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -89,7 +96,7 @@ import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.DeviceLimitException
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
import com.ponzischeme89.memby.performance.PerformanceMonitor
|
||||
@@ -102,6 +109,7 @@ import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -126,6 +134,8 @@ class MainActivity : ComponentActivity() {
|
||||
private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
val repo = ServiceLocator.repository
|
||||
var settings by remember { mutableStateOf<Settings?>(null) }
|
||||
var addingProfile by rememberSaveable { mutableStateOf(false) }
|
||||
var startingFirstRun by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(repo) {
|
||||
repo.settingsFlow.collect { settings = it }
|
||||
}
|
||||
@@ -134,155 +144,473 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
val loaded = settings
|
||||
when {
|
||||
loaded == null -> MembyLoadingScreen()
|
||||
addingProfile -> SetupScreen(
|
||||
onCancel = { addingProfile = false },
|
||||
onSignedIn = { addingProfile = false },
|
||||
)
|
||||
loaded.isSignedIn -> {
|
||||
BackHandler(onBack = onCloseSettings)
|
||||
key(loaded.userId, loaded.serverUrl) {
|
||||
HomeScreen(loaded)
|
||||
HomeScreen(
|
||||
settings = loaded,
|
||||
onAddProfile = { addingProfile = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(loaded)
|
||||
else -> SetupScreen()
|
||||
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(
|
||||
settings = loaded,
|
||||
onAddProfile = { addingProfile = true },
|
||||
)
|
||||
startingFirstRun -> SetupScreen(
|
||||
onCancel = { startingFirstRun = false },
|
||||
onSignedIn = { startingFirstRun = false },
|
||||
)
|
||||
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MembyLoadingScreen() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
val transition = rememberInfiniteTransition(label = "cold-start")
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0.96f,
|
||||
targetValue = 1.04f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(750),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "cold-start-logo-pulse",
|
||||
)
|
||||
val glow by transition.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 0.78f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(750),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "cold-start-glow",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color(0xFF16231E), Color(0xFF090C0F)),
|
||||
radius = 1_100f,
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo),
|
||||
contentDescription = "Emby",
|
||||
modifier = Modifier.width(92.dp).height(76.dp),
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
contentDescription = "Memby",
|
||||
modifier = Modifier
|
||||
.width(82.dp)
|
||||
.height(68.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = pulse
|
||||
scaleY = pulse
|
||||
alpha = 0.82f + (glow * 0.18f)
|
||||
},
|
||||
)
|
||||
Text(
|
||||
"Opening Memby…",
|
||||
color = Color.White.copy(alpha = 0.88f),
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.width(92.dp)
|
||||
.height(2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.10f)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(glow)
|
||||
.height(2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF52B54B)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirstRunScreen(onGetStarted: () -> Unit) {
|
||||
val getStartedFocus = remember { FocusRequester() }
|
||||
var entered by remember { mutableStateOf(false) }
|
||||
val contentAlpha by animateFloatAsState(
|
||||
targetValue = if (entered) 1f else 0f,
|
||||
animationSpec = tween(420),
|
||||
label = "first-run-content-alpha",
|
||||
)
|
||||
val contentOffset by animateDpAsState(
|
||||
targetValue = if (entered) 0.dp else 18.dp,
|
||||
animationSpec = tween(420),
|
||||
label = "first-run-content-offset",
|
||||
)
|
||||
val ambient = rememberInfiniteTransition(label = "first-run-ambient")
|
||||
val logoScale by ambient.animateFloat(
|
||||
initialValue = 0.97f,
|
||||
targetValue = 1.04f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1_800),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "first-run-logo-scale",
|
||||
)
|
||||
val haloAlpha by ambient.animateFloat(
|
||||
initialValue = 0.10f,
|
||||
targetValue = 0.22f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1_800),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "first-run-halo",
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
entered = true
|
||||
kotlinx.coroutines.delay(180L)
|
||||
runCatching { getStartedFocus.requestFocus() }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF0A0E11),
|
||||
Color(0xFF101A17),
|
||||
Color(0xFF090B0D),
|
||||
),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.offset(x = 70.dp)
|
||||
.size(560.dp)
|
||||
.graphicsLayer { alpha = haloAlpha }
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color(0xFF52B54B), Color.Transparent),
|
||||
),
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
Text("Emby is loading", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Preparing your library…", color = Color(0xFF9EA6AD), fontSize = 16.sp)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 88.dp, vertical = 64.dp)
|
||||
.graphicsLayer { alpha = contentAlpha }
|
||||
.offset(y = contentOffset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(590.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text(
|
||||
"WELCOME TO MEMBY",
|
||||
color = Color(0xFF69CD61),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
Text(
|
||||
"Who’s watching?",
|
||||
color = Color.White,
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Sign in to bring your Emby library, progress and favourites together on this TV.",
|
||||
color = Color(0xFFB8C0C6),
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 29.sp,
|
||||
)
|
||||
Text(
|
||||
"You can add the rest of the household afterwards.",
|
||||
color = Color(0xFF818C94),
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onGetStarted,
|
||||
modifier = Modifier.focusRequester(getStartedFocus),
|
||||
) {
|
||||
Text("Get started")
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(330.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(250.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = logoScale
|
||||
scaleY = logoScale
|
||||
alpha = haloAlpha
|
||||
}
|
||||
.background(Color(0xFF52B54B), CircleShape),
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.width(178.dp)
|
||||
.height(148.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = logoScale
|
||||
scaleY = logoScale
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupScreen(
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onSignedIn: () -> Unit = {},
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// A build that hardwires the server (memby.serverUrl) never asks for an address.
|
||||
val hardwiredHost = ServerConfig.displayHost
|
||||
var serverUrl by rememberSaveable { mutableStateOf(if (hardwiredHost != null) "" else "http://") }
|
||||
val suggestedName = remember(context) { suggestedDeviceName(context) }
|
||||
var deviceName by rememberSaveable {
|
||||
mutableStateOf(
|
||||
ServiceLocator.settings.current?.deviceName
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: suggestedName,
|
||||
)
|
||||
}
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var maxClientsPerUser by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
val serverFocus = remember { FocusRequester() }
|
||||
val usernameFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(hardwiredHost) {
|
||||
runCatching { if (hardwiredHost != null) usernameFocus.requestFocus() else serverFocus.requestFocus() }
|
||||
val deviceNameFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(repo) {
|
||||
maxClientsPerUser = runCatching { repo.authPolicy()?.maxClientsPerUser }
|
||||
.getOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(100L)
|
||||
runCatching { deviceNameFocus.requestFocus() }
|
||||
}
|
||||
if (onCancel != null) BackHandler(onBack = onCancel)
|
||||
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 96.dp, vertical = 56.dp)
|
||||
.width(720.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text("Connect to Memby", color = Color.White, fontSize = 40.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
if (hardwiredHost != null) {
|
||||
"Sign in with your Emby username and password."
|
||||
} else {
|
||||
"Enter your Emby server address and sign in."
|
||||
},
|
||||
color = Color(0xFFB9C0C7),
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
|
||||
if (hardwiredHost != null) {
|
||||
Text("Server: $hardwiredHost", color = Color(0xFF9AA3AC), fontSize = 15.sp)
|
||||
} else {
|
||||
TvTextField(
|
||||
label = "Server address (e.g. http://192.168.1.10:8096)",
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it; error = null },
|
||||
modifier = Modifier.focusRequester(serverFocus),
|
||||
keyboardType = KeyboardType.Uri,
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color(0xFF172126), Color(0xFF090C0F)),
|
||||
radius = 1_250f,
|
||||
),
|
||||
)
|
||||
}
|
||||
TvTextField(
|
||||
label = "Username",
|
||||
value = username,
|
||||
onValueChange = { username = it; error = null },
|
||||
modifier = Modifier.focusRequester(usernameFocus),
|
||||
)
|
||||
TvTextField(
|
||||
label = "Password",
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
isPassword = true,
|
||||
)
|
||||
|
||||
error?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 16.sp) }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (hardwiredHost == null && serverUrl.isBlank()) {
|
||||
error = "Server address is required."
|
||||
return@Button
|
||||
}
|
||||
if (username.isBlank()) {
|
||||
error = "Username is required."
|
||||
return@Button
|
||||
}
|
||||
connecting = true
|
||||
error = null
|
||||
scope.launch {
|
||||
runCatching { repo.authenticate(serverUrl, username, password) }
|
||||
.onFailure { error = "Sign-in failed: ${it.message}" }
|
||||
connecting = false
|
||||
}
|
||||
},
|
||||
.padding(horizontal = 72.dp, vertical = 44.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(620.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(Color(0xF2161C20))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 44.dp, vertical = 38.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text(if (connecting) "Connecting…" else "Connect")
|
||||
}
|
||||
if (onCancel != null) {
|
||||
Button(onClick = onCancel) { Text("Cancel") }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
contentDescription = "Memby",
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
if (onCancel != null) "Add another viewer" else "Welcome to Memby",
|
||||
color = Color.White,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Sign in with an Emby account",
|
||||
color = Color(0xFF9EA9B1),
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (onCancel != null) {
|
||||
"Their account will be saved as another profile on this TV."
|
||||
} else {
|
||||
"Enter your username and password to start watching."
|
||||
},
|
||||
color = Color(0xFFBAC2C8),
|
||||
fontSize = 17.sp,
|
||||
)
|
||||
|
||||
TvTextField(
|
||||
label = "Name this TV",
|
||||
value = deviceName,
|
||||
onValueChange = { deviceName = it.take(80); error = null },
|
||||
modifier = Modifier.focusRequester(deviceNameFocus),
|
||||
)
|
||||
Text(
|
||||
"We suggested this from your TV. A room name such as “Dad’s TV” makes it easier to recognise later.",
|
||||
color = Color(0xFF89959D),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
|
||||
TvTextField(
|
||||
label = "Username",
|
||||
value = username,
|
||||
onValueChange = { username = it; error = null },
|
||||
modifier = Modifier,
|
||||
)
|
||||
TvTextField(
|
||||
label = "Password",
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
isPassword = true,
|
||||
)
|
||||
|
||||
maxClientsPerUser?.let { maximum ->
|
||||
Text(
|
||||
"Each Emby account can be signed in on up to $maximum Memby devices.",
|
||||
color = Color(0xFF76C970),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
|
||||
error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 16.sp) }
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (deviceName.isBlank()) {
|
||||
error = "Give this TV a name."
|
||||
return@Button
|
||||
}
|
||||
if (username.isBlank()) {
|
||||
error = "Username is required."
|
||||
return@Button
|
||||
}
|
||||
connecting = true
|
||||
error = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
repo.authenticate(
|
||||
serverUrl = "",
|
||||
username = username.trim(),
|
||||
password = password,
|
||||
deviceName = deviceName.trim(),
|
||||
)
|
||||
}
|
||||
.onSuccess { onSignedIn() }
|
||||
.onFailure { failure ->
|
||||
error = when (failure) {
|
||||
is DeviceLimitException ->
|
||||
"Device limit reached: this account is using " +
|
||||
"${failure.activeClients} of ${failure.maxClients} devices. " +
|
||||
"Sign out on another TV, then try again."
|
||||
else ->
|
||||
"Memby couldn't sign in. Check the username and password and try again."
|
||||
}
|
||||
}
|
||||
connecting = false
|
||||
}
|
||||
},
|
||||
enabled = !connecting,
|
||||
) {
|
||||
Text(if (connecting) "Signing in…" else "Sign in")
|
||||
}
|
||||
if (onCancel != null) {
|
||||
Button(
|
||||
onClick = onCancel,
|
||||
enabled = !connecting,
|
||||
) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun suggestedDeviceName(context: android.content.Context): String {
|
||||
val systemName = runCatching {
|
||||
AndroidSettings.Global.getString(context.contentResolver, "device_name")
|
||||
}.getOrNull()?.trim().orEmpty()
|
||||
if (systemName.isNotBlank()) return systemName.take(80)
|
||||
|
||||
val manufacturer = Build.MANUFACTURER
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.takeUnless { it.equals("unknown", ignoreCase = true) }
|
||||
.orEmpty()
|
||||
val model = Build.MODEL
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.takeUnless { it.equals("unknown", ignoreCase = true) }
|
||||
.orEmpty()
|
||||
val inferred = listOf(manufacturer, model)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctBy { it.lowercase() }
|
||||
.joinToString(" ")
|
||||
return inferred.ifBlank { "Living room TV" }.take(80)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileEntryScreen(settings: Settings) {
|
||||
private fun ProfileEntryScreen(
|
||||
settings: Settings,
|
||||
onAddProfile: () -> Unit,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val scope = rememberCoroutineScope()
|
||||
var addingProfile by remember { mutableStateOf(false) }
|
||||
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
if (addingProfile) {
|
||||
SetupScreen(onCancel = { addingProfile = false })
|
||||
} else {
|
||||
ProfileChooser(
|
||||
profiles = settings.profiles,
|
||||
currentProfileId = null,
|
||||
switchingProfileId = switchingProfileId,
|
||||
onSelect = { profile ->
|
||||
switchingProfileId = profile.id
|
||||
scope.launch {
|
||||
runCatching { repo.switchProfile(profile) }
|
||||
.onFailure { switchingProfileId = null }
|
||||
}
|
||||
},
|
||||
onAddProfile = { addingProfile = true },
|
||||
onClose = null,
|
||||
)
|
||||
}
|
||||
ProfileChooser(
|
||||
profiles = settings.profiles,
|
||||
currentProfileId = null,
|
||||
switchingProfileId = switchingProfileId,
|
||||
onSelect = { profile ->
|
||||
switchingProfileId = profile.id
|
||||
scope.launch {
|
||||
runCatching { repo.switchProfile(profile) }
|
||||
.onFailure { switchingProfileId = null }
|
||||
}
|
||||
},
|
||||
onAddProfile = onAddProfile,
|
||||
onClose = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -311,11 +639,11 @@ private fun ProfileChooser(
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo),
|
||||
contentDescription = "Emby",
|
||||
contentDescription = "Memby",
|
||||
modifier = Modifier.width(72.dp).height(60.dp),
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text("Who’s watching Emby?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("Who’s watching?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
if (switchingProfileId == null) "Choose a profile to continue" else "Switching profile…",
|
||||
color = Color(0xFFAAB1B7),
|
||||
@@ -336,7 +664,7 @@ private fun ProfileChooser(
|
||||
)
|
||||
}
|
||||
ProfileTile(
|
||||
name = "Add someone else",
|
||||
name = "Add another user",
|
||||
current = false,
|
||||
enabled = switchingProfileId == null,
|
||||
symbol = "+",
|
||||
@@ -346,7 +674,7 @@ private fun ProfileChooser(
|
||||
}
|
||||
if (onClose != null) {
|
||||
Spacer(Modifier.height(30.dp))
|
||||
Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Emby") }
|
||||
Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Memby") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,40 +726,71 @@ private fun ProfileTile(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeScreen(settings: Settings) {
|
||||
private fun HomeScreen(
|
||||
settings: Settings,
|
||||
onAddProfile: () -> Unit,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val factory = remember(repo) { HomeViewModelFactory(repo) }
|
||||
val homeViewModel: HomeViewModel = viewModel(factory = factory)
|
||||
val homeState by homeViewModel.state.collectAsStateWithLifecycle()
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var showProfiles by remember { mutableStateOf(false) }
|
||||
var addProfile by remember { mutableStateOf(false) }
|
||||
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||
var navigationExpanded by remember { mutableStateOf(false) }
|
||||
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||
var launchingItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||
var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var returnItemId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val navigationFocusRequester = remember { FocusRequester() }
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
val cardReturnFocusRequester = remember { FocusRequester() }
|
||||
var initialFocusRequested by remember { mutableStateOf(false) }
|
||||
val playItem: (BaseItem) -> Unit = { item ->
|
||||
|
||||
LaunchedEffect(liveMaintenance) {
|
||||
if (liveMaintenance != null) {
|
||||
// Maintenance is an app-wide interruption, not another overlay in the stack.
|
||||
showSettings = false
|
||||
showProfiles = false
|
||||
detailsItem = null
|
||||
quickMenuItem = null
|
||||
} else if (homeState.maintenanceMessage != null) {
|
||||
// A successful live status is authoritative; refresh content immediately
|
||||
// instead of waiting for the maintenance screen's slower retry timer.
|
||||
homeViewModel.refreshAll()
|
||||
}
|
||||
}
|
||||
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
||||
if (launchingItem != null || !item.membyPlayable) return@playItem
|
||||
launchingItem = item
|
||||
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
||||
scope.launch {
|
||||
runCatching { repo.resolvePlayable(item) }.onSuccess { playable ->
|
||||
context.startActivity(
|
||||
PlayerActivity.intent(
|
||||
context = context,
|
||||
itemId = playable.itemId,
|
||||
url = playable.url,
|
||||
title = playable.title,
|
||||
resumePositionMs = playable.resumePositionMs,
|
||||
),
|
||||
)
|
||||
try {
|
||||
runCatching { repo.resolvePlayable(item) }
|
||||
.onSuccess { playable ->
|
||||
context.startActivity(
|
||||
PlayerActivity.intent(
|
||||
context = context,
|
||||
itemId = playable.itemId,
|
||||
url = playable.url,
|
||||
title = playable.title,
|
||||
resumePositionMs = playable.resumePositionMs,
|
||||
logoUrl = playable.logoUrl,
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
),
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
launchingItem = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,7 +850,7 @@ private fun HomeScreen(settings: Settings) {
|
||||
.fillMaxSize()
|
||||
.offset(x = contentShift),
|
||||
) {
|
||||
val maintenanceMessage = homeState.maintenanceMessage
|
||||
val maintenanceMessage = liveMaintenance?.message ?: homeState.maintenanceMessage
|
||||
if (maintenanceMessage != null) {
|
||||
// The rows are gone but the rail is not: Settings and Switch user are
|
||||
// local, so there is no reason to strand the viewer here.
|
||||
@@ -575,13 +934,13 @@ private fun HomeScreen(settings: Settings) {
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
|
||||
detailsItem = item
|
||||
if (item.membyPlayable) detailsItem = item
|
||||
},
|
||||
onItemLongPressed = { item ->
|
||||
returnRowId = row.id
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
quickMenuItem = item
|
||||
if (item.membyPlayable) quickMenuItem = item
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -589,6 +948,11 @@ private fun HomeScreen(settings: Settings) {
|
||||
}
|
||||
}
|
||||
}
|
||||
HomeClock(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 18.dp),
|
||||
)
|
||||
if (showSettings) {
|
||||
BackHandler(onBack = { showSettings = false })
|
||||
SettingsSheet(editableServer = true, onClose = { showSettings = false })
|
||||
@@ -612,14 +976,11 @@ private fun HomeScreen(settings: Settings) {
|
||||
},
|
||||
onAddProfile = {
|
||||
showProfiles = false
|
||||
addProfile = true
|
||||
onAddProfile()
|
||||
},
|
||||
onClose = { showProfiles = false },
|
||||
)
|
||||
}
|
||||
if (addProfile) {
|
||||
SetupScreen(onCancel = { addProfile = false })
|
||||
}
|
||||
detailsItem?.let { selected ->
|
||||
BackHandler {
|
||||
detailsItem = null
|
||||
@@ -656,9 +1017,15 @@ private fun HomeScreen(settings: Settings) {
|
||||
},
|
||||
)
|
||||
}
|
||||
launchingItem?.let { item ->
|
||||
PlaybackLaunchOverlay(
|
||||
item = item,
|
||||
modifier = Modifier.fillMaxSize().zIndex(9f),
|
||||
)
|
||||
}
|
||||
// Last in the Box, so it draws over everything — including the rail and any
|
||||
// overlay that happened to be open when the check came back.
|
||||
homeState.update?.let { update ->
|
||||
homeState.update?.takeIf { liveMaintenance == null }?.let { update ->
|
||||
UpdateScreen(
|
||||
update = update,
|
||||
onDismiss = homeViewModel::dismissUpdatePrompt,
|
||||
@@ -668,6 +1035,79 @@ private fun HomeScreen(settings: Settings) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) {
|
||||
val rotation by rememberInfiniteTransition(label = "playback-launch").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 850, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "playback-logo-rotation",
|
||||
)
|
||||
Box(
|
||||
modifier = modifier.background(Color(0xEE050708)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width(82.dp)
|
||||
.height(70.dp)
|
||||
.graphicsLayer { rotationZ = rotation },
|
||||
)
|
||||
Text(
|
||||
"Starting ${item.name}",
|
||||
color = Color.White,
|
||||
fontSize = 21.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
"Preparing direct playback…",
|
||||
color = Color(0xFF9FA7AD),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeClock(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) }
|
||||
var currentTime by remember { mutableStateOf(Date()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
currentTime = Date()
|
||||
// Wake on the next minute boundary instead of drifting by a few seconds
|
||||
// every time the app is left open.
|
||||
val untilNextMinute = 60_000L - (System.currentTimeMillis() % 60_000L)
|
||||
kotlinx.coroutines.delay(untilNextMinute)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = timeFormatter.format(currentTime),
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xB30A0D10))
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeArtworkPreloader(
|
||||
rows: List<HomeBrowseRow>,
|
||||
@@ -804,13 +1244,18 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
|
||||
.map { row ->
|
||||
HomeBrowseRow(
|
||||
id = row.id,
|
||||
title = row.title,
|
||||
title = if (row.kind == "favorites") {
|
||||
personalizedFavoritesTitle(settings.username)
|
||||
} else {
|
||||
row.title
|
||||
},
|
||||
items = row.items,
|
||||
kind = when (row.kind) {
|
||||
"continue" -> MediaRowKind.CONTINUE
|
||||
"nextup" -> MediaRowKind.NEXT_UP
|
||||
"favorites" -> MediaRowKind.FAVORITES
|
||||
"shows" -> MediaRowKind.SHOWS
|
||||
"schedule" -> MediaRowKind.SHOWS
|
||||
// Recommendation strips, "latest", and any kind a future server
|
||||
// sends get poster cards, which suit mixed movie/series rows.
|
||||
else -> MediaRowKind.MOVIES
|
||||
@@ -821,6 +1266,7 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
|
||||
"nextup" -> "You're all caught up"
|
||||
"favorites" -> "Your favourites will appear here"
|
||||
"latest" -> "No recent movies found"
|
||||
"schedule" -> "No monitored shows are airing today"
|
||||
else -> "Nothing to show here yet"
|
||||
},
|
||||
showSecondaryMetadata = settings.showHomeCardMetadata,
|
||||
@@ -862,7 +1308,7 @@ private fun homeRowsFor(
|
||||
)
|
||||
val favorites = HomeBrowseRow(
|
||||
id = "favorites",
|
||||
title = "Favourites",
|
||||
title = personalizedFavoritesTitle(settings.username),
|
||||
items = state.favorites,
|
||||
kind = MediaRowKind.FAVORITES,
|
||||
loading = HomeSection.FAVORITES in state.loading,
|
||||
@@ -914,6 +1360,33 @@ private fun homeRowsFor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns Emby account-style usernames into a friendlier home-screen name.
|
||||
*
|
||||
* Household accounts commonly use a trailing capital as a disambiguating surname
|
||||
* initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary
|
||||
* usernames such as PJ, CHRIS, alice, or MattCohen are left alone.
|
||||
*/
|
||||
internal fun friendlyProfileName(username: String?): String? {
|
||||
val name = username?.trim().orEmpty()
|
||||
if (name.isEmpty()) return null
|
||||
return if (
|
||||
name.length >= 3 &&
|
||||
name.last().isUpperCase() &&
|
||||
name[name.lastIndex - 1].isLowerCase()
|
||||
) {
|
||||
name.dropLast(1)
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
internal fun personalizedFavoritesTitle(username: String?): String {
|
||||
val name = friendlyProfileName(username) ?: return "Favorites"
|
||||
val possessive = if (name.endsWith("s", ignoreCase = true)) "$name'" else "$name's"
|
||||
return "$possessive Favorites"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderAction(label: String, onClick: () -> Unit) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.text.format.DateFormat
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
@@ -15,14 +23,23 @@ import androidx.media3.common.TrackGroup
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import coil.load
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.ui.MainActivity
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Fullscreen Media3 player with native stream-track selection. Press Menu while
|
||||
@@ -37,6 +54,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var playbackStarted = false
|
||||
private var stopReported = false
|
||||
private var itemId: String? = null
|
||||
private var remainingView: TextView? = null
|
||||
private var finishTimeView: TextView? = null
|
||||
private var loadingView: View? = null
|
||||
private var loadingAnimator: ObjectAnimator? = null
|
||||
private var renderedFirstFrame = false
|
||||
private var requestStartedAtMs = 0L
|
||||
|
||||
@UnstableApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -44,6 +67,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
val url = intent.getStringExtra(EXTRA_URL)
|
||||
requestStartedAtMs = intent.getLongExtra(
|
||||
EXTRA_REQUEST_STARTED_AT_MS,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
itemId = intent.getStringExtra(EXTRA_ITEM_ID)
|
||||
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
|
||||
if (url.isNullOrBlank()) {
|
||||
@@ -51,25 +78,54 @@ class PlayerActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
val view = PlayerView(this).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setContentView(R.layout.activity_player)
|
||||
val view = findViewById<PlayerView>(R.id.player_view).apply {
|
||||
useController = true
|
||||
setShowSubtitleButton(true)
|
||||
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
|
||||
}
|
||||
setContentView(view)
|
||||
playerView = view
|
||||
loadingView = findViewById(R.id.playback_loading)
|
||||
loadingAnimator = ObjectAnimator.ofFloat(
|
||||
findViewById<ImageView>(R.id.playback_loading_logo),
|
||||
View.ROTATION,
|
||||
0f,
|
||||
360f,
|
||||
).apply {
|
||||
duration = 850L
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
interpolator = LinearInterpolator()
|
||||
start()
|
||||
}
|
||||
remainingView = findViewById(R.id.player_remaining)
|
||||
finishTimeView = findViewById(R.id.player_finish_time)
|
||||
bindTitleArtwork(
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||
logoUrl = intent.getStringExtra(EXTRA_LOGO_URL),
|
||||
)
|
||||
|
||||
val selector = DefaultTrackSelector(this)
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
MIN_BUFFER_MS,
|
||||
MAX_BUFFER_MS,
|
||||
BUFFER_FOR_PLAYBACK_MS,
|
||||
BUFFER_AFTER_REBUFFER_MS,
|
||||
)
|
||||
.setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build()
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setTrackSelector(selector)
|
||||
.setLoadControl(loadControl)
|
||||
.build()
|
||||
.also { playback ->
|
||||
view.player = playback
|
||||
playback.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> showPlaybackLoading()
|
||||
Player.STATE_READY -> if (renderedFirstFrame) hidePlaybackLoading()
|
||||
}
|
||||
if (playbackState == Player.STATE_READY && !playbackStarted) {
|
||||
playbackStarted = true
|
||||
reportStarted(playback.currentPosition)
|
||||
@@ -80,11 +136,95 @@ class PlayerActivity : ComponentActivity() {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (playbackStarted) reportProgress(playback.currentPosition, isPaused = !isPlaying)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
updatePlaybackTiming(player)
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
renderedFirstFrame = true
|
||||
hidePlaybackLoading()
|
||||
Log.i(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"First video frame rendered in " +
|
||||
"${SystemClock.elapsedRealtime() - requestStartedAtMs} ms",
|
||||
)
|
||||
}
|
||||
})
|
||||
playback.setMediaItem(MediaItem.fromUri(url), resumePositionMs)
|
||||
playback.playWhenReady = true
|
||||
playback.prepare()
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
ServiceLocator.maintenance.notice.filterNotNull().collect {
|
||||
interruptForMaintenance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPlaybackLoading() {
|
||||
loadingView?.visibility = View.VISIBLE
|
||||
if (loadingAnimator?.isStarted != true) loadingAnimator?.start()
|
||||
}
|
||||
|
||||
private fun hidePlaybackLoading() {
|
||||
loadingView?.visibility = View.GONE
|
||||
loadingAnimator?.cancel()
|
||||
}
|
||||
|
||||
private fun bindTitleArtwork(title: String, logoUrl: String?) {
|
||||
val logo = findViewById<ImageView>(R.id.player_title_logo)
|
||||
val fallback = findViewById<TextView>(R.id.player_title).apply {
|
||||
text = title.ifBlank { "Now playing" }
|
||||
}
|
||||
if (logoUrl.isNullOrBlank()) {
|
||||
logo.visibility = View.GONE
|
||||
fallback.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
logo.load(logoUrl) {
|
||||
crossfade(false)
|
||||
listener(
|
||||
onSuccess = { _, _ ->
|
||||
logo.visibility = View.VISIBLE
|
||||
fallback.visibility = View.GONE
|
||||
},
|
||||
onError = { _, _ ->
|
||||
logo.visibility = View.GONE
|
||||
fallback.visibility = View.VISIBLE
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlaybackTiming(playback: Player) {
|
||||
val duration = playback.duration
|
||||
if (duration == C.TIME_UNSET || duration <= 0L) {
|
||||
remainingView?.text = if (playback.isCurrentMediaItemLive) "Live" else "Loading duration…"
|
||||
finishTimeView?.text = ""
|
||||
return
|
||||
}
|
||||
|
||||
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
|
||||
val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f)
|
||||
val wallClockRemainingMs = (remainingMs / speed).toLong()
|
||||
remainingView?.text = formatRemaining(wallClockRemainingMs)
|
||||
val finishAt = System.currentTimeMillis() + wallClockRemainingMs
|
||||
finishTimeView?.text = "Ends at ${DateFormat.getTimeFormat(this).format(Date(finishAt))}"
|
||||
}
|
||||
|
||||
private fun interruptForMaintenance() {
|
||||
player?.pause()
|
||||
progressJob?.cancel()
|
||||
startActivity(
|
||||
Intent(this, MainActivity::class.java).addFlags(
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
||||
),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
@@ -159,6 +299,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
progressJob?.cancel()
|
||||
loadingAnimator?.cancel()
|
||||
val playback = player
|
||||
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
|
||||
stopReported = true
|
||||
@@ -189,7 +330,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
progressJob = lifecycleScope.launch {
|
||||
while (isActive) {
|
||||
delay(PROGRESS_INTERVAL_MS)
|
||||
player?.let { reportProgress(it.currentPosition, isPaused = !it.isPlaying) }
|
||||
player?.let {
|
||||
reportProgress(it.currentPosition, isPaused = !it.isPlaying)
|
||||
updatePlaybackTiming(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +345,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val EXTRA_ITEM_ID = "extra_item_id"
|
||||
private const val EXTRA_TITLE = "extra_title"
|
||||
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
|
||||
private const val EXTRA_LOGO_URL = "extra_logo_url"
|
||||
private const val EXTRA_REQUEST_STARTED_AT_MS = "extra_request_started_at_ms"
|
||||
|
||||
fun intent(
|
||||
context: Context,
|
||||
@@ -215,14 +361,34 @@ class PlayerActivity : ComponentActivity() {
|
||||
url: String,
|
||||
title: String?,
|
||||
resumePositionMs: Long = 0L,
|
||||
logoUrl: String? = null,
|
||||
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
||||
): Intent =
|
||||
Intent(context, PlayerActivity::class.java).apply {
|
||||
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
|
||||
putExtra(EXTRA_URL, url)
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
|
||||
logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
|
||||
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
|
||||
}
|
||||
|
||||
private const val PROGRESS_INTERVAL_MS = 10_000L
|
||||
private const val CONTROLLER_TIMEOUT_MS = 6_000
|
||||
private const val MIN_BUFFER_MS = 10_000
|
||||
private const val MAX_BUFFER_MS = 30_000
|
||||
private const val BUFFER_FOR_PLAYBACK_MS = 750
|
||||
private const val BUFFER_AFTER_REBUFFER_MS = 1_500
|
||||
private const val PLAYBACK_LOG_TAG = "MembyPlayback"
|
||||
|
||||
internal fun formatRemaining(durationMs: Long): String {
|
||||
val totalMinutes = ceil(durationMs.coerceAtLeast(0L) / 60_000.0).toLong()
|
||||
return when {
|
||||
totalMinutes <= 1L -> "Less than a minute remaining"
|
||||
totalMinutes < 60L -> "$totalMinutes min remaining"
|
||||
totalMinutes % 60L == 0L -> "${totalMinutes / 60L} hr remaining"
|
||||
else -> "${totalMinutes / 60L} hr ${totalMinutes % 60L} min remaining"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<resources>
|
||||
<string name="player_title_logo">Title logo</string>
|
||||
<string name="playback_loading">Starting playback…</string>
|
||||
<string name="playback_loading_hint">Connecting directly to Emby</string>
|
||||
<string name="app_name">Memby</string>
|
||||
<string name="screensaver_name">Memby Screensaver</string>
|
||||
<string name="dream_description">Memby movie & TV backdrops</string>
|
||||
|
||||
@@ -79,6 +79,44 @@ class GatewayPayloadTest {
|
||||
assertEquals("Solaris", home.rows.last().items.single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the informational Sonarr schedule row`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows": [{
|
||||
"id":"sonarr-airing-today",
|
||||
"title":"Shows airing today",
|
||||
"kind":"schedule",
|
||||
"items":[{
|
||||
"Id":"sonarr:7:42",
|
||||
"Name":"Northbound",
|
||||
"Type":"MembySonarrEpisode",
|
||||
"ImageTags":{"Primary":"sonarr"},
|
||||
"MembySource":"sonarr",
|
||||
"MembyEpisodeTitle":"The Crossing",
|
||||
"MembyEpisodeCode":"S02E04",
|
||||
"MembyAirLabel":"Airs today at 8:00 PM",
|
||||
"MembyAvailability":"downloading",
|
||||
"MembyAvailabilityText":"Downloading",
|
||||
"MembyPlayable":false
|
||||
}]
|
||||
}],
|
||||
"continueWatching":[],
|
||||
"nextUp":[],
|
||||
"favorites":[],
|
||||
"latestMovies":[],
|
||||
"partial":false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
|
||||
|
||||
assertTrue(item.isSonarrSchedule)
|
||||
assertEquals("S02E04", item.membyEpisodeCode)
|
||||
assertEquals("Downloading", item.membyAvailabilityText)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a home payload without rows still decodes`() {
|
||||
// The gateway omits recommendation rows while they are still building, and an
|
||||
@@ -106,4 +144,14 @@ class GatewayPayloadTest {
|
||||
assertEquals(42_000L, playback.resumePositionMs)
|
||||
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device allowance response becomes a specific sign-in error`() {
|
||||
val error = parseDeviceLimit(
|
||||
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
|
||||
)
|
||||
|
||||
assertEquals(3, error?.activeClients)
|
||||
assertEquals(3, error?.maxClients)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,32 @@ class ServerHomeRowsTest {
|
||||
assertEquals("Recommended", rows.last().title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `favorites row uses the friendly profile name`() {
|
||||
val peter = serverHomeRows(
|
||||
HomeUiState(rows = serverRows, loading = emptySet()),
|
||||
Settings(username = "PeterC"),
|
||||
)
|
||||
val paul = serverHomeRows(
|
||||
HomeUiState(rows = serverRows, loading = emptySet()),
|
||||
Settings(username = "PaulR"),
|
||||
)
|
||||
|
||||
assertEquals("Peter's Favorites", peter.first { it.id == "favorites" }.title)
|
||||
assertEquals("Paul's Favorites", paul.first { it.id == "favorites" }.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `friendly profile name only removes a clear trailing initial`() {
|
||||
assertEquals("Peter", friendlyProfileName("PeterC"))
|
||||
assertEquals("Paul", friendlyProfileName(" PaulR "))
|
||||
assertEquals("MattCohen", friendlyProfileName("MattCohen"))
|
||||
assertEquals("CHRIS", friendlyProfileName("CHRIS"))
|
||||
assertEquals("PJ", friendlyProfileName("PJ"))
|
||||
assertEquals("Chris' Favorites", personalizedFavoritesTitle("Chris"))
|
||||
assertEquals("Favorites", personalizedFavoritesTitle(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling a section hides its rows but never the recommendations`() {
|
||||
val settings = Settings(homeSections = "continue")
|
||||
@@ -67,6 +93,20 @@ class ServerHomeRowsTest {
|
||||
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
|
||||
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
|
||||
|
||||
val rows = serverHomeRows(
|
||||
HomeUiState(rows = listOf(schedule), loading = emptySet()),
|
||||
Settings(homeSections = ""),
|
||||
)
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
|
||||
assertEquals("No monitored shows are airing today", rows.single().emptyMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown row kind from a newer server still renders`() {
|
||||
val rows = serverHomeRows(
|
||||
|
||||
@@ -8,6 +8,11 @@ services:
|
||||
- "${MEMBY_PORT:-8080}:8080"
|
||||
environment:
|
||||
MEMBY_LISTEN_ADDR: ":8080"
|
||||
# INFO keeps Docker logs concise. Temporarily use DEBUG to include successful
|
||||
# health checks, maintenance polling and artwork requests.
|
||||
MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}"
|
||||
# Local day boundaries and schedule labels for Sonarr's "airing today" row.
|
||||
MEMBY_TIMEZONE: "${MEMBY_TIMEZONE:-Pacific/Auckland}"
|
||||
# How the gateway reaches Emby.
|
||||
MEMBY_EMBY_URL: "${MEMBY_EMBY_URL:?set MEMBY_EMBY_URL in .env}"
|
||||
# What the TVs are told to stream from. Only set this when it differs from the
|
||||
@@ -16,15 +21,30 @@ services:
|
||||
MEMBY_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable"
|
||||
MEMBY_REDIS_URL: "redis://redis:6379/0"
|
||||
MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}"
|
||||
# Strict per-Emby-user TV allowance. Signing the same physical TV in again
|
||||
# replaces its token and does not consume another slot.
|
||||
MEMBY_MAX_CLIENTS_PER_USER: "${MEMBY_MAX_CLIENTS_PER_USER:-1}"
|
||||
# Unset disables /admin entirely — the library import, maintenance switch and
|
||||
# analytics page all live behind it.
|
||||
MEMBY_ADMIN_TOKEN: "${MEMBY_ADMIN_TOKEN:-}"
|
||||
# CI publishes signed APKs here. The dedicated token does not grant access to the
|
||||
# rest of the admin API.
|
||||
MEMBY_PUBLIC_URL: "${MEMBY_PUBLIC_URL:-https://mserver.sublogue.com}"
|
||||
MEMBY_RELEASE_DIR: "/data/releases"
|
||||
MEMBY_RELEASE_PUBLISH_TOKEN: "${MEMBY_RELEASE_PUBLISH_TOKEN:-}"
|
||||
# Hourly incremental import: enough for episodes landing through the day, and
|
||||
# films appearing weekly ride along.
|
||||
MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}"
|
||||
MEMBY_SYNC_ON_START: "${MEMBY_SYNC_ON_START:-false}"
|
||||
MEMBY_SYNC_USER_ID: "${MEMBY_SYNC_USER_ID:-}"
|
||||
MEMBY_SYNC_API_KEY: "${MEMBY_SYNC_API_KEY:-}"
|
||||
# Optional read-only Sonarr calendar integration. Both values are required to
|
||||
# enable it; the API key remains inside the gateway.
|
||||
MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}"
|
||||
MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}"
|
||||
MEMBY_SONARR_TTL: "${MEMBY_SONARR_TTL:-5m}"
|
||||
volumes:
|
||||
- memby-releases:/data/releases
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -66,3 +86,4 @@ services:
|
||||
|
||||
volumes:
|
||||
memby-postgres:
|
||||
memby-releases:
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ memby.serverUrl=https://molise.bounceme.net
|
||||
|
||||
# The Memby gateway container (server/). When set, the app talks to it instead of Emby
|
||||
# and the address above is only used as the fallback path. Blank = talk to Emby directly.
|
||||
memby.gatewayUrl=https://memby.bounceme.net
|
||||
memby.gatewayUrl=https://mserver.sublogue.com
|
||||
|
||||
# Enabled parallel sync for Gradle 9.4+
|
||||
org.gradle.tooling.parallel=true
|
||||
|
||||
+4
-3
@@ -52,12 +52,13 @@ if ($Version) {
|
||||
# Same scheme as app/build.gradle.kts: major*10000 + minor*100 + patch.
|
||||
$code = [int]$parts[0] * 10000 + [int]$parts[1] * 100 + [int]$parts[2]
|
||||
|
||||
$gradle = $gradle -replace 'versionCode = \d+', "versionCode = $code"
|
||||
$gradle = $gradle -replace 'versionName = "[^"]*"', "versionName = `"$Version`""
|
||||
$gradle = $gradle -replace 'val defaultVersionName = "[^"]*"', "val defaultVersionName = `"$Version`""
|
||||
Set-Content -LiteralPath $gradleFile -Value $gradle -NoNewline
|
||||
Write-Host "Set version $Version (versionCode $code)" -ForegroundColor Cyan
|
||||
} else {
|
||||
if ($gradle -notmatch 'versionName = "([^"]*)"') { throw 'Could not read versionName from app/build.gradle.kts' }
|
||||
if ($gradle -notmatch 'val defaultVersionName = "([^"]*)"') {
|
||||
throw 'Could not read defaultVersionName from app/build.gradle.kts'
|
||||
}
|
||||
$Version = $Matches[1]
|
||||
Write-Host "Building existing version $Version" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN mkdir -p /out/releases
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
|
||||
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
|
||||
|
||||
@@ -16,6 +17,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/memby-server /app/memby-server
|
||||
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app/memby-server"]
|
||||
|
||||
+93
-9
@@ -35,6 +35,17 @@ go run ./cmd/memby-server # needs Postgres + Redis reachable
|
||||
|
||||
On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`.
|
||||
|
||||
## Logs
|
||||
|
||||
The server writes one compact, structured text line per event, designed for
|
||||
`docker compose logs -f server`. At the default `MEMBY_LOG_LEVEL=INFO`, successful
|
||||
health checks, live maintenance polls and artwork requests are hidden; warnings and
|
||||
failures from those routes are still shown.
|
||||
|
||||
Set `MEMBY_LOG_LEVEL=DEBUG` temporarily and recreate the server container when those
|
||||
high-frequency requests are useful during diagnosis. Library imports log their start,
|
||||
progress after each 500-item page, and final counts and duration.
|
||||
|
||||
## API
|
||||
|
||||
All `/v1` routes need `Authorization: Bearer <token>` from `/v1/auth/login`. Image URLs
|
||||
@@ -43,9 +54,11 @@ headers attached.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| POST | `/v1/auth/login` | Emby credentials in, gateway token out |
|
||||
| POST | `/v1/auth/login` | Emby credentials + device identity in, gateway token out |
|
||||
| GET | `/v1/auth/policy` | Public client allowance used by the sign-in screen |
|
||||
| POST | `/v1/auth/logout` | Retire this device's token |
|
||||
| GET | `/v1/auth/session` | Confirm a stored token is still valid |
|
||||
| GET | `/v1/status` | Lightweight live maintenance state; remains available during maintenance |
|
||||
| GET | `/v1/home?limit=` | **Every launcher row in one response** |
|
||||
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
|
||||
| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update |
|
||||
@@ -71,10 +84,14 @@ four fixed rows repeated flat for the client's offline cache:
|
||||
"rows": [
|
||||
{"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]},
|
||||
{"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]},
|
||||
{"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "items": [...]},
|
||||
{"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]},
|
||||
{"id": "latest-movies", "title": "Recently Added Movies", "kind": "latest", "items": [...]},
|
||||
{"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]},
|
||||
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]}
|
||||
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]},
|
||||
{"id": "curated:apple-tv", "title": "Apple TV+ Shows", "kind": "shows", "items": [...]},
|
||||
{"id": "curated:drama-shows", "title": "Drama TV Shows", "kind": "shows", "items": [...]},
|
||||
{"id": "curated:comedy-shows", "title": "Comedy TV Shows", "kind": "shows", "items": [...]}
|
||||
],
|
||||
"continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...],
|
||||
"partial": false
|
||||
@@ -98,11 +115,16 @@ invented — nobody opted out of a row that did not exist when they last opened
|
||||
recent), favourites add a smaller fixed weight, and candidates are unplayed titles in the
|
||||
top three genres scored by affinity + a mild community-rating nudge. Titles tagged with
|
||||
many genres get a `sqrt(n)` penalty so genre-stuffing cannot buy a top slot.
|
||||
- **Curated TV shelves** — Apple TV+, Drama and Comedy membership is filtered from the
|
||||
imported Postgres catalogue. Within each shelf, unseen shows are ranked using the same
|
||||
personal genre/studio profile; the shelves themselves are ordered by affinity too.
|
||||
A new profile falls back to community rating until it has viewing history.
|
||||
|
||||
Rows shorter than four items are dropped, and a user with no history gets no rows at all
|
||||
rather than a strip of noise.
|
||||
rather than a strip of noise, except curated shelves: these remain useful with their
|
||||
quality-ranked fallback.
|
||||
|
||||
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows`
|
||||
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows:v2`
|
||||
cache key with a long TTL (2h). A cache miss serves home immediately without them and
|
||||
triggers a background rebuild — deduplicated per user, so four TVs waking together do the
|
||||
work once. Because the key sits outside the `u:` namespace, a favourite toggle does not
|
||||
@@ -112,11 +134,28 @@ that genuinely changes viewing history.
|
||||
The scoring is pure and unit-tested (`profile_test.go`), and the row assembly runs against
|
||||
a fake Emby (`engine_test.go`), so neither needs a server to verify.
|
||||
|
||||
Curated shelves require at least one completed library import because their genre/studio
|
||||
filter runs against Postgres. Emby's exact studio naming is preserved during import;
|
||||
Apple matching accepts `Apple TV+`, `Apple TV Plus` and `Apple Studios`.
|
||||
|
||||
Item payloads are Emby's own JSON, forwarded verbatim. That is deliberate: the Android
|
||||
client already models this shape, so there is no second schema to keep in sync.
|
||||
`app/src/test/.../GatewayPayloadTest.kt` and `internal/api/api_test.go` pin the envelope
|
||||
around it from both sides.
|
||||
|
||||
### Sonarr schedule
|
||||
|
||||
When `MEMBY_SONARR_URL` and `MEMBY_SONARR_API_KEY` are set, the gateway reads Sonarr's
|
||||
v3 calendar and inserts **Shows airing today** directly after Next Up. Cards show the
|
||||
local air time, season/episode number, episode title and one of: upcoming, downloading,
|
||||
awaiting download, unmonitored, or the exact time Sonarr added the episode file.
|
||||
|
||||
This row is informational. An episode listed before it is downloaded is not an Emby
|
||||
item, so the TV deliberately does not offer Play, Favourite or Watched actions on it.
|
||||
Sonarr poster and fanart requests are proxied through the gateway; its API key is never
|
||||
sent to the TV. The shared Redis entry is refreshed every five minutes by default, not
|
||||
once per user.
|
||||
|
||||
## Admin interface
|
||||
|
||||
`http://<host>:8080/admin/` — a single self-contained page for library imports, the
|
||||
@@ -167,8 +206,10 @@ candidate pool.
|
||||
|
||||
Takes Memby down independently of Emby: all `/v1` routes answer `503` with
|
||||
`{"maintenance": true, "message": "…"}`, and the TV shows the operator's message instead of
|
||||
a network error. `/healthz`, `/readyz` and `/admin` stay up — they are what you need while
|
||||
the app is deliberately off.
|
||||
a network error. The exact `/v1/status` control route stays up too: open clients poll it
|
||||
every ten seconds, stop active playback, and move immediately to the maintenance screen.
|
||||
`/healthz`, `/readyz` and `/admin` also stay up — they are what you need while the app is
|
||||
deliberately off.
|
||||
|
||||
The switch lives in Postgres, not memory, so a restart cannot quietly bring the app back
|
||||
up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it
|
||||
@@ -177,8 +218,40 @@ directly in the database works too.
|
||||
## App update policy
|
||||
|
||||
The gateway decides whether a TV may keep running its current build. Clients send
|
||||
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch; the verdict is
|
||||
`none`, `optional` or `mandatory`.
|
||||
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch and hourly
|
||||
while left open; the verdict is `none`, `optional` or `mandatory`.
|
||||
|
||||
### Automated tagged releases
|
||||
|
||||
`.gitea/workflows/release.yml` turns a pushed semantic-version tag into an update:
|
||||
|
||||
1. Gitea Actions derives the Android version from a tag such as `v0.1.54`.
|
||||
2. It runs the tests and builds the APK with the established release signing key.
|
||||
3. It uploads the signed APK to `POST /admin/api/release`.
|
||||
4. The gateway stores the APK in its `memby-releases` volume and makes it the latest
|
||||
optional update. Older TVs prompt on their next check.
|
||||
|
||||
The repository needs an Actions runner with the `ubuntu-latest` label and these Actions
|
||||
secrets:
|
||||
|
||||
| Secret | Purpose |
|
||||
| --- | --- |
|
||||
| `ANDROID_KEYSTORE_BASE64` | Existing release keystore, base64 encoded as one line |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | Keystore password |
|
||||
| `ANDROID_KEY_ALIAS` | Signing alias |
|
||||
| `ANDROID_KEY_PASSWORD` | Key password |
|
||||
| `MEMBY_RELEASE_PUBLISH_TOKEN` | Same dedicated value configured on the gateway |
|
||||
|
||||
Enable repository Actions and give the job permission to read contents. Then releasing
|
||||
from any clone is:
|
||||
|
||||
```bash
|
||||
git tag -a v0.1.54 -m "Personalised favourites"
|
||||
git push origin main v0.1.54
|
||||
```
|
||||
|
||||
Tags are the release boundary; an ordinary branch push never publishes an APK. Android
|
||||
will only accept updates signed by the same keystore as the installed app.
|
||||
|
||||
Set it on the admin page: **latest version**, **APK URL** (normally the same file the
|
||||
landing page serves), release notes, and a **Require this update** toggle.
|
||||
@@ -229,7 +302,9 @@ served but never cached. The `r:` namespace is deliberately excluded from that w
|
||||
Recommendations above).
|
||||
|
||||
Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache,
|
||||
losing Postgres signs everyone out.
|
||||
losing Postgres signs everyone out. A session is unique per Emby user and stable device
|
||||
ID; signing the same TV in again rotates its token, while a new TV is rejected once
|
||||
`MEMBY_MAX_CLIENTS_PER_USER` is reached.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -240,18 +315,27 @@ losing Postgres signs everyone out.
|
||||
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
|
||||
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
|
||||
| `MEMBY_LISTEN_ADDR` | `:8080` | |
|
||||
| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests |
|
||||
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
|
||||
| `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list |
|
||||
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
|
||||
| `MEMBY_RECOMMEND_TTL` | `2h` | How long computed recommendation rows stay warm |
|
||||
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
|
||||
| `MEMBY_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled |
|
||||
| `MEMBY_PUBLIC_URL` | *empty* | Public gateway origin used for APK download links |
|
||||
| `MEMBY_RELEASE_DIR` | `/data/releases` | Persistent signed APK directory |
|
||||
| `MEMBY_RELEASE_PUBLISH_TOKEN` | *empty* | Enables the CI-only release upload endpoint |
|
||||
| `MEMBY_SYNC_INTERVAL` | `1h` | Incremental import cadence; `0` disables |
|
||||
| `MEMBY_SYNC_TIMEOUT` | `30m` | Bounds one import |
|
||||
| `MEMBY_SYNC_ON_START` | `false` | Import at boot |
|
||||
| `MEMBY_SONARR_URL` | *empty* | Sonarr address reachable by the gateway; empty disables integration |
|
||||
| `MEMBY_SONARR_API_KEY` | *empty* | Sonarr Settings → General → Security API key |
|
||||
| `MEMBY_SONARR_TTL` | `5m` | Shared Redis lifetime for today's calendar |
|
||||
| `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports |
|
||||
| `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this |
|
||||
| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis |
|
||||
| `MEMBY_SESSION_IDLE_EXPIRY` | `2160h` (90d) | Unused tokens are swept every 6h |
|
||||
| `MEMBY_MAX_CLIENTS_PER_USER` | `1` | Strict maximum number of distinct signed-in TVs per Emby user |
|
||||
| `MEMBY_UPSTREAM_TIMEOUT` | `20s` | |
|
||||
|
||||
## Security notes
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -20,7 +21,9 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/library"
|
||||
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -37,7 +40,8 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
|
||||
log := logging.New(os.Stdout, logLevel)
|
||||
|
||||
if err := run(log); err != nil {
|
||||
log.Error("fatal", "error", err)
|
||||
@@ -82,6 +86,34 @@ func run(log *slog.Logger) error {
|
||||
}
|
||||
|
||||
embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout)
|
||||
retiredSessions, err := st.TrimSessionsToLimit(ctx, cfg.MaxClientsPerUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sess := range retiredSessions {
|
||||
_ = ca.Delete(ctx, cache.SessionKey(hex.EncodeToString(sess.TokenHash)))
|
||||
if err := embyClient.Logout(ctx, emby.Credentials{
|
||||
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
|
||||
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
|
||||
}); err != nil {
|
||||
log.Warn("could not retire excess emby session",
|
||||
"username", sess.Username,
|
||||
"device", sess.DeviceName,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(retiredSessions) > 0 {
|
||||
log.Info("enforced per-user device allowance",
|
||||
"retired_sessions", len(retiredSessions),
|
||||
"max_clients_per_user", cfg.MaxClientsPerUser,
|
||||
)
|
||||
}
|
||||
var sonarrClient *sonarr.Client
|
||||
if cfg.SonarrURL != "" {
|
||||
sonarrClient = sonarr.New(cfg.SonarrURL, cfg.SonarrAPIKey, cfg.UpstreamTimeout)
|
||||
log.Info("sonarr integration enabled", "url", cfg.SonarrURL)
|
||||
}
|
||||
|
||||
recommender := recommend.NewEngine(embyClient, log)
|
||||
// Candidates come from the imported library when one exists, which keeps the
|
||||
@@ -99,6 +131,7 @@ func run(log *slog.Logger) error {
|
||||
Store: st,
|
||||
Cache: ca,
|
||||
Recommender: recommender,
|
||||
Sonarr: sonarrClient,
|
||||
Syncer: syncer,
|
||||
Log: log,
|
||||
})
|
||||
@@ -135,7 +168,11 @@ func run(log *slog.Logger) error {
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("listening", "addr", cfg.ListenAddr, "emby", cfg.EmbyURL)
|
||||
log.Info("server ready",
|
||||
"listen", cfg.ListenAddr,
|
||||
"emby", cfg.EmbyURL,
|
||||
"sync_every", cfg.SyncInterval,
|
||||
)
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -97,6 +97,28 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
|
||||
server := testServer(config.Config{})
|
||||
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back after dinner"})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleServiceStatus(
|
||||
rec,
|
||||
httptest.NewRequest(http.MethodGet, "/v1/status", nil),
|
||||
store.Session{},
|
||||
)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status endpoint returned %d", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["maintenance"] != true || body["message"] != "Back after dinner" {
|
||||
t.Fatalf("unexpected status response: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
|
||||
server := testServer(config.Config{})
|
||||
|
||||
|
||||
@@ -18,12 +18,14 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -33,8 +35,10 @@ type Server struct {
|
||||
store *store.Store
|
||||
cache *cache.Cache
|
||||
recommender *recommend.Engine
|
||||
sonarr *sonarr.Client
|
||||
syncer syncerHandle
|
||||
log *slog.Logger
|
||||
sonarrMu sync.Mutex
|
||||
|
||||
recommendationBuilds recommendationBuilds
|
||||
maintenance maintenanceState
|
||||
@@ -48,17 +52,22 @@ type Deps struct {
|
||||
Store *store.Store
|
||||
Cache *cache.Cache
|
||||
Recommender *recommend.Engine
|
||||
Sonarr *sonarr.Client
|
||||
Syncer syncerHandle
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func New(cfg config.Config, deps Deps) *Server {
|
||||
if cfg.MaxClientsPerUser < 1 {
|
||||
cfg.MaxClientsPerUser = 1
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
emby: deps.Emby,
|
||||
store: deps.Store,
|
||||
cache: deps.Cache,
|
||||
recommender: deps.Recommender,
|
||||
sonarr: deps.Sonarr,
|
||||
syncer: deps.Syncer,
|
||||
log: deps.Log,
|
||||
}
|
||||
@@ -70,6 +79,7 @@ func (s *Server) Routes() http.Handler {
|
||||
v1 := http.NewServeMux()
|
||||
|
||||
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
|
||||
v1.HandleFunc("GET /v1/auth/policy", s.handleAuthPolicy)
|
||||
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
|
||||
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
|
||||
|
||||
@@ -93,8 +103,12 @@ func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
mux.HandleFunc("GET /readyz", s.handleReady)
|
||||
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
|
||||
// status even while every normal /v1 operation is deliberately unavailable.
|
||||
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
|
||||
mux.Handle("/v1/", s.maintenanceGate(v1))
|
||||
mux.Handle("/admin/", s.adminRoutes())
|
||||
mux.HandleFunc("GET /updates/{filename}", s.handleReleaseDownload)
|
||||
|
||||
return s.withLogging(mux)
|
||||
}
|
||||
@@ -134,15 +148,33 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
// Path only: query strings can carry image tokens.
|
||||
s.log.Info("request",
|
||||
level := requestLogLevel(r.URL.Path, rec.status)
|
||||
s.log.Log(r.Context(), level, "HTTP request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"ms", time.Since(start).Milliseconds(),
|
||||
"duration", time.Since(start).Round(time.Millisecond),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Successful high-frequency probes and artwork fetches stay available at DEBUG without
|
||||
// overwhelming the normal Docker log. Failures are always promoted so they remain
|
||||
// visible regardless of path.
|
||||
func requestLogLevel(path string, status int) slog.Level {
|
||||
switch {
|
||||
case status >= http.StatusInternalServerError:
|
||||
return slog.LevelError
|
||||
case status >= http.StatusBadRequest:
|
||||
return slog.LevelWarn
|
||||
case path == "/healthz", path == "/readyz", path == "/v1/status",
|
||||
strings.HasPrefix(path, "/v1/images/"):
|
||||
return slog.LevelDebug
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
@@ -184,6 +216,7 @@ type cachedSession struct {
|
||||
Username string `json:"n"`
|
||||
ServerID string `json:"s"`
|
||||
DeviceID string `json:"d"`
|
||||
DeviceName string `json:"dn,omitempty"`
|
||||
}
|
||||
|
||||
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
|
||||
@@ -201,6 +234,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
|
||||
Username: cs.Username,
|
||||
ServerID: cs.ServerID,
|
||||
DeviceID: cs.DeviceID,
|
||||
DeviceName: cs.DeviceName,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -220,6 +254,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
|
||||
Username: sess.Username,
|
||||
ServerID: sess.ServerID,
|
||||
DeviceID: sess.DeviceID,
|
||||
DeviceName: sess.DeviceName,
|
||||
}); err == nil {
|
||||
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
|
||||
}
|
||||
@@ -231,7 +266,10 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
|
||||
}
|
||||
|
||||
func credentials(sess store.Session) emby.Credentials {
|
||||
return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID}
|
||||
return emby.Credentials{
|
||||
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
|
||||
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
|
||||
}
|
||||
}
|
||||
|
||||
// --- responses --------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
@@ -57,6 +58,64 @@ func TestNewTokenIsUnique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPolicyPublishesConfiguredDeviceAllowance(t *testing.T) {
|
||||
s := &Server{cfg: config.Config{MaxClientsPerUser: 4}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/auth/policy", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
s.handleAuthPolicy(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var policy authPolicyResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &policy); err != nil {
|
||||
t.Fatalf("decode policy: %v", err)
|
||||
}
|
||||
if policy.MaxClientsPerUser != 4 {
|
||||
t.Fatalf("max clients = %d, want 4", policy.MaxClientsPerUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSonarrScheduleRequiresCapableClient(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"": false,
|
||||
"0.1.53": false,
|
||||
"0.1.54": true,
|
||||
"0.2.0": true,
|
||||
}
|
||||
for version, want := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
|
||||
req.Header.Set("X-Memby-Version", version)
|
||||
if got := supportsSonarrSchedule(req); got != want {
|
||||
t.Errorf("supportsSonarrSchedule(%q) = %v, want %v", version, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) {
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v1/items/42/playback?type=Movie&title=Arrival&resumePositionMs=12000",
|
||||
nil,
|
||||
)
|
||||
item, ok := playbackHint(req, "42")
|
||||
if !ok {
|
||||
t.Fatal("valid hint was rejected")
|
||||
}
|
||||
if item.ID != "42" || item.Type != "Movie" || item.Name != "Arrival" {
|
||||
t.Fatalf("unexpected hinted item: %+v", item)
|
||||
}
|
||||
if item.UserData.PlaybackPositionTicks != 12_000*ticksPerMillisecond {
|
||||
t.Fatalf("resume ticks = %d", item.UserData.PlaybackPositionTicks)
|
||||
}
|
||||
|
||||
bad := httptest.NewRequest(http.MethodGet, "/v1/items/42/playback?type=Playlist", nil)
|
||||
if _, ok := playbackHint(bad, "42"); ok {
|
||||
t.Fatal("unsupported type should fall back to Emby")
|
||||
}
|
||||
}
|
||||
|
||||
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the
|
||||
// client's non-null List fields.
|
||||
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
|
||||
|
||||
+78
-16
@@ -2,24 +2,39 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
ServerID string `json:"serverId"`
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
ServerID string `json:"serverId"`
|
||||
ActiveClients int `json:"activeClients,omitempty"`
|
||||
MaxClientsPerUser int `json:"maxClientsPerUser"`
|
||||
}
|
||||
|
||||
type authPolicyResponse struct {
|
||||
MaxClientsPerUser int `json:"maxClientsPerUser"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthPolicy(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, authPolicyResponse{
|
||||
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogin exchanges Emby credentials for a gateway token.
|
||||
@@ -40,8 +55,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if req.DeviceID == "" {
|
||||
req.DeviceID = "memby-tv"
|
||||
}
|
||||
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
||||
if req.DeviceName == "" {
|
||||
// Compatibility for APKs released before device naming. New clients require an
|
||||
// editable name in their UI, but an older TV must still be able to sign in while
|
||||
// the household rollout is in progress.
|
||||
req.DeviceName = "Memby TV"
|
||||
}
|
||||
if len([]rune(req.DeviceName)) > 80 {
|
||||
writeError(w, http.StatusBadRequest, "device name is too long")
|
||||
return
|
||||
}
|
||||
|
||||
auth, err := s.emby.Authenticate(r.Context(), req.Username, req.Password, req.DeviceID)
|
||||
auth, err := s.emby.Authenticate(
|
||||
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName,
|
||||
)
|
||||
if err != nil {
|
||||
// Never echo Emby's body here: a failed sign-in is the one place a wrong
|
||||
// password could be reflected back.
|
||||
@@ -64,21 +92,54 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Username: auth.User.Name,
|
||||
ServerID: auth.ServerID,
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
}
|
||||
if sess.Username == "" {
|
||||
sess.Username = req.Username
|
||||
}
|
||||
if err := s.store.CreateSession(r.Context(), sess); err != nil {
|
||||
replacedHash, activeClients, err := s.store.CreateSession(
|
||||
r.Context(), sess, s.cfg.MaxClientsPerUser,
|
||||
)
|
||||
if errors.Is(err, store.ErrDeviceLimit) {
|
||||
if revokeErr := s.emby.Logout(r.Context(), emby.Credentials{
|
||||
UserID: auth.User.ID, Token: auth.AccessToken,
|
||||
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
|
||||
}); revokeErr != nil {
|
||||
s.log.Warn("could not retire refused emby session", "error", revokeErr)
|
||||
}
|
||||
s.log.Warn("device allowance reached",
|
||||
"username", sess.Username,
|
||||
"active_clients", activeClients,
|
||||
"max_clients", s.cfg.MaxClientsPerUser,
|
||||
)
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "device_limit_reached",
|
||||
"message": "This account has reached its Memby device allowance.",
|
||||
"activeClients": activeClients,
|
||||
"maxClientsPerUser": s.cfg.MaxClientsPerUser,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
_ = s.emby.Logout(r.Context(), emby.Credentials{
|
||||
UserID: auth.User.ID, Token: auth.AccessToken,
|
||||
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
|
||||
})
|
||||
s.log.Error("session persist failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not start a session")
|
||||
return
|
||||
}
|
||||
if len(replacedHash) > 0 {
|
||||
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: token,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
ServerID: sess.ServerID,
|
||||
Token: token,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
ServerID: sess.ServerID,
|
||||
ActiveClients: activeClients,
|
||||
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,8 +155,9 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
|
||||
// handleSession lets the TV confirm a stored token is still good before rendering.
|
||||
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) {
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
ServerID: sess.ServerID,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
ServerID: sess.ServerID,
|
||||
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
@@ -63,10 +64,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
|
||||
cred := credentials(sess)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
out homeResponse
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
out homeResponse
|
||||
sonarrRow *recommend.Row
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
|
||||
@@ -119,6 +121,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
"Limit": {itoa(limit)},
|
||||
}, fieldsRow))
|
||||
})
|
||||
if s.sonarr != nil && supportsSonarrSchedule(r) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
row, err := s.sonarrAiringTodayRow(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("sonarr calendar row failed", "error", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
sonarrRow = row
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
@@ -136,7 +152,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
if recommendations == nil {
|
||||
s.refreshRecommendationsInBackground(sess)
|
||||
}
|
||||
out.Rows = append(baseRows(out), recommendations...)
|
||||
rows := baseRows(out)
|
||||
if sonarrRow != nil {
|
||||
// The schedule is most useful beside Next Up, before personal collections.
|
||||
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
|
||||
}
|
||||
out.Rows = append(rows, recommendations...)
|
||||
|
||||
body, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
@@ -154,6 +175,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// Older clients render unknown rows but do not understand MembyPlayable=false, so they
|
||||
// could try to send a synthetic Sonarr id to Emby. The feature ships with 0.1.54.
|
||||
func supportsSonarrSchedule(r *http.Request) bool {
|
||||
version := clientVersion(r)
|
||||
return version != "" && appupdate.CompareVersions(version, "0.1.54") >= 0
|
||||
}
|
||||
|
||||
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
|
||||
// request, so the Dream still looks random without re-querying Emby every few seconds.
|
||||
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -29,6 +32,10 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(itemID, "sonarr:") {
|
||||
s.handleSonarrImage(w, r, itemID, imageType)
|
||||
return
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} {
|
||||
@@ -63,3 +70,48 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
|
||||
s.log.Warn("image copy failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
|
||||
if s.sonarr == nil {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(itemID, ":")
|
||||
if len(parts) != 3 {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
seriesID, err := strconv.Atoi(parts[1])
|
||||
if err != nil || seriesID <= 0 {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
coverType := map[string]string{"Primary": "poster", "Backdrop": "fanart"}[imageType]
|
||||
if coverType == "" {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
resp, err := s.sonarr.MediaCover(r.Context(), seriesID, coverType)
|
||||
if err != nil {
|
||||
var apiErr *sonarr.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
|
||||
writeError(w, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
s.log.Warn("sonarr image failed", "series_id", seriesID, "type", coverType, "error", err)
|
||||
writeError(w, http.StatusBadGateway, "could not load the image")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
||||
w.Header().Set("Content-Length", cl)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
s.log.Warn("sonarr image copy failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,3 +85,18 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceStatus is the live control channel clients poll while the app is open.
|
||||
// It sits outside maintenanceGate so maintenance can interrupt playback rather than only
|
||||
// being discovered the next time a content request happens to run.
|
||||
func (s *Server) handleServiceStatus(w http.ResponseWriter, _ *http.Request, _ store.Session) {
|
||||
state := s.maintenance.get()
|
||||
message := state.Message
|
||||
if state.Enabled && message == "" {
|
||||
message = store.DefaultMaintenanceMessage
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"maintenance": state.Enabled,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
@@ -40,15 +41,18 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
}
|
||||
cred := credentials(sess)
|
||||
|
||||
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not load the item")
|
||||
return
|
||||
}
|
||||
item, err := emby.Summarise(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "unreadable item from emby")
|
||||
return
|
||||
item, hinted := playbackHint(r, itemID)
|
||||
if !hinted {
|
||||
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not load the item")
|
||||
return
|
||||
}
|
||||
item, err = emby.Summarise(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "unreadable item from emby")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
target := item
|
||||
@@ -78,6 +82,31 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
})
|
||||
}
|
||||
|
||||
// Current clients already know the selected item's type, title and cached resume point.
|
||||
// Accepting those as hints removes one serial Emby request from every launch. Older
|
||||
// clients omit them and retain the authoritative lookup above.
|
||||
func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
|
||||
itemType := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||
switch {
|
||||
case strings.EqualFold(itemType, "Movie"):
|
||||
itemType = "Movie"
|
||||
case strings.EqualFold(itemType, "Episode"):
|
||||
itemType = "Episode"
|
||||
case strings.EqualFold(itemType, "Series"):
|
||||
itemType = "Series"
|
||||
default:
|
||||
return emby.Summary{}, false
|
||||
}
|
||||
resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64)
|
||||
item := emby.Summary{
|
||||
ID: itemID,
|
||||
Name: strings.TrimSpace(r.URL.Query().Get("title")),
|
||||
Type: itemType,
|
||||
}
|
||||
item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond
|
||||
return item, true
|
||||
}
|
||||
|
||||
// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
|
||||
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
|
||||
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
|
||||
|
||||
@@ -79,6 +79,10 @@ func Decide(policy Policy, clientVersion string) Decision {
|
||||
return none
|
||||
}
|
||||
|
||||
// CompareVersions returns -1, 0, or 1 and is shared by policy decisions and the release
|
||||
// publisher's downgrade guard.
|
||||
func CompareVersions(a, b string) int { return compare(parseVersion(a), parseVersion(b)) }
|
||||
|
||||
// compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0.
|
||||
func compare(a, b []int) int {
|
||||
for i := 0; i < len(a) || i < len(b); i++ {
|
||||
|
||||
Vendored
+1
-1
@@ -87,7 +87,7 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
|
||||
// Recommendations cost several Emby queries to build, so they must survive the cache
|
||||
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
|
||||
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
|
||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows", userID) }
|
||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v2", userID) }
|
||||
|
||||
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
|
||||
return c.Delete(ctx, RecommendationsKey(userID))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -33,6 +34,8 @@ type Config struct {
|
||||
SessionTTL time.Duration
|
||||
// SessionIdleExpiry retires gateway tokens that go unused for this long.
|
||||
SessionIdleExpiry time.Duration
|
||||
// MaxClientsPerUser is enforced transactionally when a new TV signs in.
|
||||
MaxClientsPerUser int
|
||||
|
||||
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
|
||||
// taste moves slowly and each rebuild costs several Emby queries.
|
||||
@@ -47,6 +50,14 @@ type Config struct {
|
||||
// unconfigured deployment cannot leave it exposed.
|
||||
AdminToken string
|
||||
|
||||
// PublicURL is the externally reachable Memby gateway address used in update links.
|
||||
PublicURL string
|
||||
// ReleaseDir persists signed APKs published by CI.
|
||||
ReleaseDir string
|
||||
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
|
||||
// from AdminToken so a compromised build runner cannot change maintenance settings.
|
||||
ReleasePublishToken string
|
||||
|
||||
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
||||
SyncInterval time.Duration
|
||||
// SyncTimeout bounds one import; a full pass over a large library is slow.
|
||||
@@ -60,6 +71,13 @@ type Config struct {
|
||||
|
||||
// AnalyticsRetention is how long raw row events are kept before being pruned.
|
||||
AnalyticsRetention time.Duration
|
||||
|
||||
// Sonarr is optional. When configured, its local calendar supplies the informational
|
||||
// "Shows airing today" home row. The API key never leaves this server.
|
||||
SonarrURL string
|
||||
SonarrAPIKey string
|
||||
SonarrTTL time.Duration
|
||||
SonarrLocation *time.Location
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -76,10 +94,16 @@ func Load() (Config, error) {
|
||||
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
|
||||
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
|
||||
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
|
||||
MaxClientsPerUser: integer("MEMBY_MAX_CLIENTS_PER_USER", 1),
|
||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
|
||||
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
||||
|
||||
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")), "/"),
|
||||
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
||||
ReleasePublishToken: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
|
||||
),
|
||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||
@@ -87,6 +111,9 @@ func Load() (Config, error) {
|
||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
||||
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
|
||||
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
|
||||
}
|
||||
|
||||
if c.EmbyURL == "" {
|
||||
@@ -95,9 +122,23 @@ func Load() (Config, error) {
|
||||
if c.DatabaseURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
|
||||
}
|
||||
if c.MaxClientsPerUser < 1 {
|
||||
return c, fmt.Errorf("MEMBY_MAX_CLIENTS_PER_USER must be at least 1")
|
||||
}
|
||||
if c.EmbyPublicURL == "" {
|
||||
c.EmbyPublicURL = c.EmbyURL
|
||||
}
|
||||
if c.ReleasePublishToken != "" && c.PublicURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_PUBLIC_URL is required when release publishing is enabled")
|
||||
}
|
||||
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
|
||||
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
|
||||
}
|
||||
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
|
||||
}
|
||||
c.SonarrLocation = location
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -134,3 +175,15 @@ func duration(key string, fallback time.Duration) time.Duration {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func integer(key string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ type Client struct {
|
||||
|
||||
// Credentials identify one signed-in Emby user.
|
||||
type Credentials struct {
|
||||
UserID string
|
||||
Token string
|
||||
DeviceID string
|
||||
UserID string
|
||||
Token string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type ItemsResult struct {
|
||||
@@ -82,13 +83,13 @@ func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID string) (*AuthResult, error) {
|
||||
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName string) (*AuthResult, error) {
|
||||
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
|
||||
Credentials{DeviceID: deviceID}, bytes.NewReader(body))
|
||||
Credentials{DeviceID: deviceID, DeviceName: deviceName}, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,6 +105,16 @@ func (c *Client) Authenticate(ctx context.Context, username, password, deviceID
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Logout retires a token created during authentication. The gateway uses this when a
|
||||
// device is refused by policy so Emby is not left holding an orphaned session.
|
||||
func (c *Client) Logout(ctx context.Context, cred Credentials) error {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/Sessions/Logout", nil, cred, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
|
||||
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
|
||||
}
|
||||
@@ -285,10 +296,14 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
|
||||
if deviceID == "" {
|
||||
deviceID = "memby-gateway"
|
||||
}
|
||||
deviceName := strings.TrimSpace(cred.DeviceName)
|
||||
if deviceName == "" {
|
||||
deviceName = "Memby TV"
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
|
||||
`MediaBrowser Client="%s", Device="Memby Gateway", DeviceId="%s", Version="1.0"`,
|
||||
c.clientName, deviceID,
|
||||
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="1.0"`,
|
||||
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
|
||||
))
|
||||
if cred.Token != "" {
|
||||
req.Header.Set("X-Emby-Token", cred.Token)
|
||||
|
||||
@@ -122,6 +122,7 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
s.log.Info("library sync started", "kind", kind, "trigger", trigger)
|
||||
result, syncErr := s.run(ctx, cred, kind, since, startedAt)
|
||||
result.Kind = kind
|
||||
result.Duration = time.Since(startedAt)
|
||||
@@ -147,7 +148,8 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
|
||||
}
|
||||
s.log.Info("library sync finished",
|
||||
"kind", kind, "trigger", trigger, "seen", result.Seen,
|
||||
"upserted", result.Upserted, "removed", result.Removed, "ms", result.DurationMs)
|
||||
"upserted", result.Upserted, "removed", result.Removed,
|
||||
"duration", result.Duration.Round(time.Millisecond))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -204,6 +206,11 @@ func (s *Syncer) run(
|
||||
|
||||
result.Seen += len(page.Items)
|
||||
result.Upserted += int(written)
|
||||
s.log.Info("library sync progress",
|
||||
"kind", kind,
|
||||
"seen", result.Seen,
|
||||
"upserted", result.Upserted,
|
||||
)
|
||||
|
||||
if len(page.Items) < pageSize {
|
||||
break
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -33,6 +34,27 @@ type LibrarySource interface {
|
||||
LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// CuratedLibrarySource is the optional richer catalogue query used for server-authored
|
||||
// collections. Keeping it separate preserves compatibility with simpler test sources.
|
||||
type CuratedLibrarySource interface {
|
||||
CuratedCandidates(
|
||||
ctx context.Context,
|
||||
itemTypes, genres, studios []string,
|
||||
limit int,
|
||||
) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
|
||||
// the user's profile determines both item order and shelf order.
|
||||
type CuratedRow struct {
|
||||
ID string
|
||||
Title string
|
||||
Kind string
|
||||
ItemTypes []string
|
||||
Genres []string
|
||||
Studios []string
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
source Source
|
||||
log *slog.Logger
|
||||
@@ -47,6 +69,7 @@ type Engine struct {
|
||||
// screen rather than a wall of near-duplicates.
|
||||
MaxSimilarRows int
|
||||
RowSize int
|
||||
CuratedRows []CuratedRow
|
||||
}
|
||||
|
||||
func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
@@ -56,6 +79,29 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
RowSize: 20,
|
||||
CuratedRows: []CuratedRow{
|
||||
{
|
||||
ID: "curated:apple-tv",
|
||||
Title: "Apple TV+ Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Studios: []string{"Apple TV+", "Apple TV Plus", "Apple Studios"},
|
||||
},
|
||||
{
|
||||
ID: "curated:drama-shows",
|
||||
Title: "Drama TV Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Drama"},
|
||||
},
|
||||
{
|
||||
ID: "curated:comedy-shows",
|
||||
Title: "Comedy TV Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Comedy"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,25 +122,77 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
|
||||
}
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
if profile.IsEmpty() {
|
||||
// A brand-new user has nothing to recommend from. No rows is the honest answer.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1)
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
|
||||
if !profile.IsEmpty() {
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
library, ok := e.Library.(CuratedLibrarySource)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
type rankedDefinition struct {
|
||||
definition CuratedRow
|
||||
affinity float64
|
||||
order int
|
||||
}
|
||||
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
|
||||
for order, definition := range e.CuratedRows {
|
||||
definitions = append(definitions, rankedDefinition{
|
||||
definition: definition,
|
||||
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
|
||||
order: order,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(definitions, func(i, j int) bool {
|
||||
if definitions[i].affinity != definitions[j].affinity {
|
||||
return definitions[i].affinity > definitions[j].affinity
|
||||
}
|
||||
return definitions[i].order < definitions[j].order
|
||||
})
|
||||
|
||||
rows := make([]Row, 0, len(definitions))
|
||||
for _, ranked := range definitions {
|
||||
definition := ranked.definition
|
||||
raws, err := library.CuratedCandidates(
|
||||
ctx,
|
||||
definition.ItemTypes,
|
||||
definition.Genres,
|
||||
definition.Studios,
|
||||
e.RowSize*6,
|
||||
)
|
||||
if err != nil {
|
||||
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
items := RankCollection(profile, Decode(raws), e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, Row{
|
||||
ID: definition.ID,
|
||||
Title: definition.Title,
|
||||
Kind: definition.Kind,
|
||||
Items: Raws(items),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// gatherSignals reads what the user has watched and favourited, in parallel.
|
||||
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
|
||||
var (
|
||||
|
||||
@@ -27,6 +27,31 @@ type fakeSource struct {
|
||||
similarSeeds []string
|
||||
}
|
||||
|
||||
type fakeCuratedLibrary struct {
|
||||
byGenre map[string][]json.RawMessage
|
||||
}
|
||||
|
||||
func (f *fakeCuratedLibrary) LibraryCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
_ int,
|
||||
) ([]json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCuratedLibrary) CuratedCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
genres, studios []string,
|
||||
_ int,
|
||||
) ([]json.RawMessage, error) {
|
||||
key := strings.Join(genres, "|")
|
||||
if len(studios) > 0 {
|
||||
key = "studio:" + studios[0]
|
||||
}
|
||||
return f.byGenre[key], nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -191,6 +216,67 @@ func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {
|
||||
raw("history", "Funny History", "Episode", "Comedy"),
|
||||
},
|
||||
},
|
||||
similar: map[string][]json.RawMessage{},
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Comedy": {
|
||||
raw("comedy-low", "Lower Rated Match", "Series", "Comedy"),
|
||||
raw("comedy-high", "Higher Rated Match", "Series", "Comedy"),
|
||||
},
|
||||
"Drama": {
|
||||
raw("drama-1", "Drama One", "Series", "Drama"),
|
||||
raw("drama-2", "Drama Two", "Series", "Drama"),
|
||||
},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{
|
||||
{ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
|
||||
{ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
|
||||
}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected two curated rows, got %v", rowTitles(rows))
|
||||
}
|
||||
if rows[0].ID != "comedy" || rows[1].ID != "drama" {
|
||||
t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows))
|
||||
}
|
||||
if rows[0].Kind != "shows" {
|
||||
t.Fatalf("curated TV shelf kind = %q", rows[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||
engine := testEngine(source)
|
||||
engine.MinRowItems = 1
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Drama": {raw("drama", "Strong Drama", "Series", "Drama")},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{{
|
||||
ID: "drama", Title: "Drama TV Shows", Kind: "shows",
|
||||
ItemTypes: []string{"Series"}, Genres: []string{"Drama"},
|
||||
}}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != "drama" {
|
||||
t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A failing similarity lookup is one dead row, not a dead home screen.
|
||||
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
|
||||
@@ -193,8 +193,39 @@ func (p Profile) Score(candidate Item) float64 {
|
||||
return genreScore + studioScore + ratingScore
|
||||
}
|
||||
|
||||
// CollectionAffinity decides which curated shelf appears first for this user.
|
||||
func (p Profile) CollectionAffinity(genres, studios []string) float64 {
|
||||
var score float64
|
||||
for _, genre := range genres {
|
||||
score += weightFold(p.GenreWeights, genre)
|
||||
}
|
||||
for _, studio := range studios {
|
||||
score += weightFold(p.StudioWeights, studio)
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func weightFold(weights map[string]float64, wanted string) float64 {
|
||||
for key, value := range weights {
|
||||
if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(wanted)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Rank scores, filters and truncates candidates, dropping duplicates by id.
|
||||
func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
return rank(profile, candidates, limit, false)
|
||||
}
|
||||
|
||||
// RankCollection keeps unseen candidates with no affinity/rating score at the end,
|
||||
// ensuring a curated shelf remains useful for a new profile or unrated library.
|
||||
func RankCollection(profile Profile, candidates []Item, limit int) []Item {
|
||||
return rank(profile, candidates, limit, true)
|
||||
}
|
||||
|
||||
func rank(profile Profile, candidates []Item, limit int, includeZero bool) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
@@ -207,7 +238,7 @@ func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
if score := profile.Score(candidate); score > 0 {
|
||||
if score := profile.Score(candidate); score > 0 || includeZero && score == 0 {
|
||||
ranked = append(ranked, scored{candidate, score})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,16 @@ func TestRankRespectsLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionAffinityIsCaseInsensitive(t *testing.T) {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{"Comedy": 2},
|
||||
StudioWeights: map[string]float64{"Apple TV+": 0.5},
|
||||
}
|
||||
if got := profile.CollectionAffinity([]string{"comedy"}, []string{"apple tv+"}); got != 2.5 {
|
||||
t.Fatalf("CollectionAffinity = %v, want 2.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKeepsRawPayload(t *testing.T) {
|
||||
raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`)
|
||||
items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)})
|
||||
|
||||
@@ -138,6 +138,51 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
|
||||
// are matched case-insensitively because Emby studio capitalisation is not consistent.
|
||||
func (s *Store) CuratedCandidates(
|
||||
ctx context.Context,
|
||||
itemTypes, genres, studios []string,
|
||||
limit int,
|
||||
) ([]json.RawMessage, error) {
|
||||
if len(itemTypes) == 0 || (len(genres) == 0 && len(studios) == 0) {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE type = ANY($1)
|
||||
AND (
|
||||
cardinality($2::text[]) = 0 OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(genres) AS genre
|
||||
WHERE lower(genre) = ANY($2)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
cardinality($3::text[]) = 0 OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(studios) AS studio
|
||||
WHERE lower(studio) = ANY($3)
|
||||
)
|
||||
)
|
||||
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
|
||||
LIMIT $4`,
|
||||
itemTypes, lowerStrings(genres), lowerStrings(studios), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: curated candidates: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
func lowerStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, strings.ToLower(strings.TrimSpace(value)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) {
|
||||
stats := LibraryStats{ByType: map[string]int64{}}
|
||||
|
||||
|
||||
@@ -10,12 +10,28 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
username TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT NOT NULL DEFAULT 'Memby TV',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
|
||||
|
||||
-- Older builds could create more than one token for the same physical TV. Keep the most
|
||||
-- recently used row before adding the identity constraint.
|
||||
DELETE FROM sessions older
|
||||
USING sessions newer
|
||||
WHERE older.emby_user_id = newer.emby_user_id
|
||||
AND older.device_id = newer.device_id
|
||||
AND (
|
||||
older.last_seen_at < newer.last_seen_at
|
||||
OR (older.last_seen_at = newer.last_seen_at AND older.token_hash < newer.token_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id);
|
||||
CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sessions_user_device_idx
|
||||
ON sessions (emby_user_id, device_id);
|
||||
|
||||
-- The imported library.
|
||||
--
|
||||
|
||||
+104
-11
@@ -17,6 +17,7 @@ var schema string
|
||||
|
||||
// ErrNotFound is returned when a token does not match a live session.
|
||||
var ErrNotFound = errors.New("store: session not found")
|
||||
var ErrDeviceLimit = errors.New("store: device limit reached")
|
||||
|
||||
type Session struct {
|
||||
TokenHash []byte
|
||||
@@ -25,6 +26,7 @@ type Session struct {
|
||||
Username string
|
||||
ServerID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
@@ -56,30 +58,73 @@ func (s *Store) Migrate(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO sessions (token_hash, emby_user_id, emby_token, username, server_id, device_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (token_hash) DO UPDATE SET
|
||||
// CreateSession enforces a user's device allowance under a per-user transaction lock.
|
||||
// Re-authenticating the same stable device replaces its token and never consumes a slot.
|
||||
// The replaced hash is returned so its Redis entry can be invalidated immediately.
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: begin session: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sess.EmbyUserID); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: lock user sessions: %w", err)
|
||||
}
|
||||
|
||||
var previousHash []byte
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT token_hash FROM sessions
|
||||
WHERE emby_user_id = $1 AND device_id = $2`,
|
||||
sess.EmbyUserID, sess.DeviceID).Scan(&previousHash)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, fmt.Errorf("store: find device session: %w", err)
|
||||
}
|
||||
existingDevice := err == nil
|
||||
|
||||
var activeClients int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT count(*) FROM sessions WHERE emby_user_id = $1`,
|
||||
sess.EmbyUserID).Scan(&activeClients); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: count user sessions: %w", err)
|
||||
}
|
||||
if !existingDevice && activeClients >= maxClients {
|
||||
return nil, activeClients, ErrDeviceLimit
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO sessions (
|
||||
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
|
||||
token_hash = EXCLUDED.token_hash,
|
||||
emby_token = EXCLUDED.emby_token,
|
||||
username = EXCLUDED.username,
|
||||
server_id = EXCLUDED.server_id,
|
||||
device_id = EXCLUDED.device_id,
|
||||
device_name = EXCLUDED.device_name,
|
||||
last_seen_at = now()`,
|
||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, sess.ServerID, sess.DeviceID)
|
||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
|
||||
sess.ServerID, sess.DeviceID, sess.DeviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: create session: %w", err)
|
||||
return nil, 0, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
return nil
|
||||
if !existingDevice {
|
||||
activeClients++
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: commit session: %w", err)
|
||||
}
|
||||
return previousHash, activeClients, nil
|
||||
}
|
||||
|
||||
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, last_seen_at
|
||||
FROM sessions WHERE token_hash = $1`, hash).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
@@ -111,3 +156,51 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// TrimSessionsToLimit brings data created under an older, more generous policy back
|
||||
// within the current allowance. The most recently active devices survive.
|
||||
func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH ranked AS (
|
||||
SELECT token_hash,
|
||||
row_number() OVER (
|
||||
PARTITION BY emby_user_id
|
||||
ORDER BY last_seen_at DESC, created_at DESC, token_hash DESC
|
||||
) AS device_rank
|
||||
FROM sessions
|
||||
),
|
||||
retired AS (
|
||||
DELETE FROM sessions current
|
||||
USING ranked
|
||||
WHERE current.token_hash = ranked.token_hash
|
||||
AND ranked.device_rank > $1
|
||||
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
|
||||
current.username, current.server_id, current.device_id,
|
||||
current.device_name, current.last_seen_at
|
||||
)
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id,
|
||||
device_id, device_name, last_seen_at
|
||||
FROM retired`,
|
||||
maxClients,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: trim sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var retired []Session
|
||||
for rows.Next() {
|
||||
var sess Session
|
||||
if err := rows.Scan(
|
||||
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
|
||||
}
|
||||
retired = append(retired, sess)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: trim sessions rows: %w", err)
|
||||
}
|
||||
return retired, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user