Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+24 -11
View File
@@ -5,14 +5,14 @@ MEMBY_EMBY_URL=https://molise.bounceme.net
# What TVs are told to stream from. Only set this when it differs from the address
# above — video goes device -> Emby directly, never through the gateway.
#MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net
MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net
# Postgres password for the memby role. Generate one, e.g.
# openssl rand -base64 24
POSTGRES_PASSWORD=change-me
POSTGRES_PASSWORD=7dfc3eb07108013c7bea9787457397551b5eb9059adac51aea4741f4ec649be2
# Host port the gateway listens on.
MEMBY_PORT=8080
# Fixed NAS host port used by the mserver.sublogue.com reverse proxy.
MEMBY_PORT=32768
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
MEMBY_LOG_LEVEL=INFO
@@ -26,10 +26,10 @@ 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
# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the
# maintenance switch, and row analytics. Leave blank to disable /admin entirely. Generate with
# openssl rand -hex 32
MEMBY_ADMIN_TOKEN=
MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd8c
# Public gateway address and a dedicated token used only by the Gitea release workflow.
# Generate the token with: openssl rand -hex 32
@@ -42,12 +42,25 @@ MEMBY_SYNC_ON_START=false
# Optional Emby service account for imports. Without it the gateway borrows the most
# recently active TV session, which works but stops if that user is removed.
#MEMBY_SYNC_USER_ID=
#MEMBY_SYNC_API_KEY=
MEMBY_SYNC_USER_ID=23bf90fc1fe049ce898db7adda1ed7bf
MEMBY_SYNC_API_KEY=56775917938841e7ac1b6a233d4d5075
# 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_URL=http://10.0.0.2:8989
MEMBY_SONARR_API_KEY=6b608b051ee24582925773bd5dfbe37a
MEMBY_SONARR_TTL=5m
# How long after an episode airs the "aired, coming soon" banner keeps being offered.
# 0 turns the banners off and leaves the airing-today row alone.
MEMBY_SONARR_ALERT_WINDOW=3h
# Optional Tracearr-powered For You signals. Create a read-only public API key in
# Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers.
MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/
MEMBY_TRACEARR_API_KEY=trr_pub_WKSdiZFGZ_10d-zgQ2Wx0H4Ym4NAiIeLBSvf8a6rvZ0
MEMBY_TRACEARR_SERVER_ID=6964b9ed-3a21-4f51-b94b-cd344ec42c1b
MEMBY_TRACEARR_SYNC_INTERVAL=5m
MEMBY_TRACEARR_FULL_INTERVAL=24h
MEMBY_FOR_YOU_MIN_REBUILD_AGE=10m
MEMBY_FOR_YOU_REFRESH_INTERVAL=30m
+118 -1
View File
@@ -46,6 +46,29 @@ go build ./... && go test ./... # add -buildvcs=false on Windows if .git is
docker compose up -d --build # from the repo root; needs .env (see .env.example)
```
**Deploying the gateway to the NAS** is `deploy-server.ps1` (PowerShell 7):
```powershell
.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby
.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test
```
It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over
one SSH connection (interactive password; stdin carries the
archive, so OpenSSH prompts on the tty). The remote half stages into
`<destination>.new.$$`, builds, then swaps directories and waits for all three health
checks, restoring the previous release if anything fails. The named Postgres volume is
preserved — it never runs `compose down -v`.
**`.env.example` is the configuration.** It holds real values, and every deployment
overwrites the NAS's `.env` with the local copy (the old one is kept beside it as
`.env.previous`). The script requires `MEMBY_PORT=32768`, `MEMBY_ADMIN_TOKEN`,
`MEMBY_EMBY_URL` and `POSTGRES_PASSWORD` before activation, then confirms the admin token
reached the running container. The database volume is always preserved; deployment stops
before activation if the Postgres password differs from the deployed value, because a
credential change requires an explicit database migration. The local working tree is
deployed directly; no commit or push is required.
`local.properties` must contain `sdk.dir=...` when building from the CLI.
Lint has `abortOnError = false` (media3's `@UnstableApi` opt-in check would otherwise fail
@@ -82,6 +105,12 @@ keystore the build still succeeds but emits an unsigned APK and logs a warning.
matters more than the code: Android identifies an app by applicationId **plus** signing
key, so a changed key forces every user to uninstall and reinstall.
For direct TV deployment without publishing a release, `deploy-tv.ps1` builds and verifies
the signed release, connects over wireless ADB, installs it with `-r`, and launches the
Leanback activity. It reads the same signing settings from the current user's persistent
`MEMBY_KEYSTORE*` environment variables and defaults to the living-room Chromecast endpoint;
pass `-Device host:port` when Android rotates the wireless-debugging port.
`UpdateChecker` supports two sources, chosen by URL shape in `isManifestUrl` — a `.json`
URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest
use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`.
@@ -142,7 +171,9 @@ the container is down. Specifics worth knowing:
gateway proxy, `api_key=` for Emby.
- Video always direct-plays from Emby. The gateway returns a URL; it never proxies a
stream. Don't route playback through it.
- Search exists on the gateway (`repository.search`) but has no UI yet.
- Search is dual-path like the rest: `/v1/search` on the gateway (Postgres full-text,
falling back to Emby before the first import), `SearchTerm` on `Users/{id}/Items`
directly. `ui/search/` renders it — see "Search" below.
The wire contract is pinned from both ends: `GatewayPayloadTest.kt` / `ServerHomeRowsTest.kt`
(Kotlin) and `internal/api/api_test.go` (Go). Change a field name or a row `kind` and one
@@ -168,6 +199,50 @@ navigation rail stays mounted beside it so Settings and Switch user still work,
retry button takes `contentFocusRequester` (with `focusProperties { left = … }` back to
the rail) because otherwise D-pad focus has nowhere to go once the rows are gone.
**Service alerts.** `/v1/status` is the only thing an open app polls continuously (10s,
`MaintenanceMonitor`), so it doubles as the push channel: alongside maintenance state it
carries an `alerts` array, and `ui/ServiceAlertBanner.kt` drops one in as a full-width bar
across the top of the screen, broadcast-notice style (it spans the navigation rail too).
The only producer today is `api/alerts.go` — an episode whose Sonarr air time has passed
but which Emby has not imported yet ("aired, coming soon"). It reads the *cached*
airing-today calendar, so polling clients never cost a Sonarr request. Things to preserve:
the server has no idea which TVs saw what, so the client dedupes by id against
`SettingsStore.markAlertSeen` (persisted, or every relaunch replays yesterday's news); an
alert is only *offered* until the banner calls `alertShown` — nothing is persisted and no
dismissal timer runs before that, so one arriving behind the screensaver waits rather than
being consumed by nobody, and `pendingAlertExpired` drops it once the gateway stops
offering it. The status loop itself runs under
`ProcessLifecycleOwner … repeatOnLifecycle(STARTED)`, so a backgrounded app stops polling
entirely instead of hitting the gateway every 10s at a TV nobody is watching. The banner is
never focusable and
times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring counting it
down — take the duration from that constant, or the ring and the timer drift apart),
because stealing D-pad focus mid-browse is worse than a missed notice;
and alerts are suppressed under maintenance and under a mandatory update, which own the
screen. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row.
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)`
`trim``distinctUntilChanged``collectLatest { repository.search(it) }` — and
`collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow
response for a prefix can never overwrite the results for what was typed after it.
Searching starts at two characters (`shouldSearch`); one letter matches half a library.
`rankSearchResults` is a pure, stable sort that only lifts exact/prefix/word-boundary
title matches above the backend's own relevance order — it never re-sorts alphabetically,
and it keeps weak matches rather than showing an empty pane. A small access-ordered map
caches results per query for the session, so backspacing is instant.
Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the
rightmost goes to the results grid, the grid's first column goes back to the *last key
used* (a `FocusRequester` attached to whichever key that is), and the grid has a
`focusRestorer`. Back moves results → keyboard → clear query → leave, one step per press.
Physical keyboards and phone-remote apps feed the same state through one
`onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back
must fall through. The voice button needs the `android.speech.RecognitionService` entry in
the manifest's `<queries>`, or `isRecognitionAvailable` returns false on Android 11+ and
it hides itself on devices that actually support it.
**Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events
with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s,
on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows
@@ -234,6 +309,20 @@ a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches
`media3-exoplayer-hls` dependency is already present for when that's added. Progress is
reported back to Emby via `reportPlaybackStarted/Progress/Stopped`.
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
→ Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from
`repository.nextEpisode`, dual-path like everything else: `/v1/items/{id}/next` on the
gateway, `Shows/{seriesId}/Episodes?AdjacentTo=` directly. Both rely on Emby returning
`[previous, current, next]` in running order, so it is the *position* of the current
episode that identifies the next one — never the length of the list, which shrinks at both
ends of a season (`episodeAfter` in `playback.go`, unit-tested). Three things are easy to
break: the countdown is driven off the playhead, not a timer of its own, so pausing holds
it and seeking backwards out of the window re-arms it; advancing swaps the `MediaItem`
inside the running player instead of relaunching the activity, so `itemId`/`playbackStarted`
/`stopReported` must all be reset together or the outgoing episode is never reported
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are
@@ -246,3 +335,31 @@ Material 3 ones. `MainActivity.kt`, `HomeComponents.kt` and `ScreensaverContent.
three large files — new screens generally belong in `ui/<feature>/` rather than growing
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
everything must be reachable by D-pad only.
**Animations must not recompose.** This app ships to weak TV boxes, so an animated value
read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is
a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and
read it inside a `Canvas`/`drawBehind` block (draw phase only), and derive any text from it
with `derivedStateOf` so it recomposes when the *displayed* value changes, not when the
float does. `ServiceAlertBanner`'s countdown ring and pulse are the worked example: ~10
recompositions of one number over ten seconds instead of ~600 of the whole bar. The same
rule applies to collecting flows — collect in the smallest composable that needs the value,
not at the top of `MainActivity`, or every emission recomposes the launcher.
**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather
than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
A preview does not run `ServiceLocator`, so only composables that take their state as
parameters are previewable — the same property that makes them unit-testable. Prefer
previewing the still inner composable over an animated wrapper (`AlertBanner`, not
`ServiceAlertBanner`): a frozen frame of a slide-in shows nothing useful.
**Screenshots.** `app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables
to PNGs under `app/build/screenshots/` via Roborazzi + Robolectric, at TV 1080p qualifiers
— the way to look at a layout without a TV to hand. This is the *only* Android dependency
allowed in `app/src/test`; keep it confined to `*ScreenshotTest.kt` files so logic tests
stay pure JUnit. Recording is always on (`roborazzi.test.record` in `testOptions`): these
are artifacts to look at, not checked-in goldens, and a screenshot test that silently
captures nothing is worse than none. AGP's own `com.android.compose.screenshot` plugin was
tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without
checking that upstream.
+12
View File
@@ -13,6 +13,9 @@ tracks during playback. The screensaver (Dream) code remains isolated from the c
- **In-app playback** with Media3/ExoPlayer (movies play directly; a series plays its
next-up / first episode).
- **Favorites** managed through the Emby API and shown on the home screen.
- A dedicated **For You** destination, shaped by Tracearr completion/device history,
Memby browsing signals and a viewer-selected 30/60/120-minute time window. Every pick
says why it was chosen and whether it has direct-play evidence on that television.
## Tech stack
@@ -93,6 +96,15 @@ new episodes), composes the home rows — including "Recommended from your watch
history" — and has an admin page at `/admin/` for imports, an offline switch, and
per-row engagement.
For Tracearr-powered recommendations, create a read-only public API key in Tracearr and
set `MEMBY_TRACEARR_URL` plus `MEMBY_TRACEARR_API_KEY` on the gateway (and optionally
`MEMBY_TRACEARR_SERVER_ID` when Tracearr monitors several servers). Tracearr credentials
remain server-side; the Android app only receives ranked Emby items and short reasons.
The gateway imports compact session signals into Postgres every five minutes and prepares
an over-provisioned per-user pool, so opening For You normally performs one indexed
database read. The live on-demand path remains available for cold starts and failed
rebuilds.
## Server address
Memby is built for one Emby server, so the address is baked into the APK instead of being
+31 -1
View File
@@ -17,7 +17,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
// 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 defaultVersionName = "0.1.69"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -116,6 +116,21 @@ android {
abortOnError = false
}
testOptions {
unitTests {
// Robolectric needs the merged resources to inflate anything; only the
// screenshot tests use them.
isIncludeAndroidResources = true
// Roborazzi writes PNGs only in record mode. These images are artifacts to
// look at, not checked-in goldens to diff against, so recording is always on
// — a screenshot test that silently captures nothing is worse than none.
all {
it.systemProperty("roborazzi.test.record", "true")
}
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -133,6 +148,10 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.work:work-runtime-ktx:2.10.0")
// ProcessLifecycleOwner: lets the status poll stop while no Memby screen is on top,
// instead of hitting the gateway every ten seconds for as long as the process lives.
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
implementation("androidx.metrics:metrics-performance:1.0.0")
@@ -167,4 +186,15 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-tooling")
testImplementation("junit:junit:4.13.2")
// Screenshot rendering only. Everything else under app/src/test stays plain JUnit
// with no Android on the classpath — see the note in CLAUDE.md. Rendering a
// composable is the one thing that genuinely cannot be done that way, and these are
// confined to *ScreenshotTest.kt files.
testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test.ext:junit:1.2.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.32.2")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.32.2")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+35 -5
View File
@@ -7,6 +7,30 @@
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Package visibility (Android 11+). Without these, resolveActivity() returns null and
"Play in Emby" / "Screensaver settings" silently do nothing. -->
<queries>
<!-- EmbyAppLauncher hands an item to an installed Emby client. -->
<package android:name="com.mb.android" />
<package android:name="tv.emby.embyatv" />
<!-- openScreensaverSettings probes the TV's own settings screens, best first. -->
<intent>
<action android:name="android.settings.DREAM_SETTINGS" />
</intent>
<intent>
<action android:name="android.settings.DISPLAY_SETTINGS" />
</intent>
<intent>
<action android:name="android.settings.SETTINGS" />
</intent>
<!-- Search's microphone button. Without this, SpeechRecognizer.isRecognitionAvailable
reports false on Android 11+ even where a recogniser exists, and the button
would be hidden on devices that support voice perfectly well. -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<!-- This is a TV app: no touchscreen, uses the Leanback launcher. -->
<uses-feature
android:name="android.hardware.touchscreen"
@@ -19,18 +43,22 @@
android:name=".MembyApp"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@drawable/app_banner"
android:icon="@drawable/emby_logo"
android:label="@string/app_name"
android:roundIcon="@drawable/emby_logo"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby">
<profileable android:shell="true" tools:targetApi="q" />
<!-- Home / setup screen. Registered on the TV (Leanback) launcher. -->
<!-- Home / setup screen. Registered on the TV (Leanback) launcher.
Televisions are fixed landscape, so the DiscouragedApi advice about adapting to
other orientations does not apply to any device that can install this app. -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:screenOrientation="landscape"
tools:ignore="DiscouragedApi"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -43,7 +71,8 @@
android:name=".ui.screensaver.ScreensaverActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.Memby.Fullscreen" />
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
the TV is never left displaying the old, black Dream surface. -->
@@ -62,14 +91,15 @@
android:screenOrientation="landscape"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
android:theme="@style/Theme.Memby.Fullscreen" />
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- The system screensaver (Daydream / Ambient mode source).
Interactive: select to open the panel, play, or favourite. -->
<service
android:name=".screensaver.MembyDreamService"
android:exported="true"
android:icon="@drawable/app_banner"
android:icon="@drawable/emby_logo"
android:label="@string/screensaver_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby
import android.annotation.SuppressLint
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
@@ -11,6 +12,9 @@ import com.ponzischeme89.memby.data.SettingsStore
* settings instance without pulling in a DI framework.
*/
object ServiceLocator {
// [init] only ever stores an applicationContext, whose lifetime is the process, so this
// holds nothing that could outlive its owner. Keep it that way.
@SuppressLint("StaticFieldLeak")
lateinit var settings: SettingsStore
private set
lateinit var repository: EmbyRepository
@@ -22,6 +26,6 @@ object ServiceLocator {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository)
maintenance = MaintenanceMonitor(repository, settings)
}
}
@@ -7,12 +7,14 @@ 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.GatewayPrerollSchedule
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
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
@@ -49,6 +51,24 @@ data class HomeSnapshot(
val partial: Boolean = false,
)
/**
* The episode that follows the one being watched, with everything the "next up" banner
* needs to render itself and then start playing without a second round trip.
*/
data class NextEpisode(
val itemId: String,
val title: String,
val seriesName: String,
val episodeCode: String?,
val imageUrl: String?,
val url: String,
val resumePositionMs: Long = 0L,
val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
/** A resolved, directly playable stream. */
data class Playable(
val itemId: String,
@@ -56,6 +76,10 @@ data class Playable(
val url: String,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
class EmbyRepository(private val settings: SettingsStore) {
@@ -82,6 +106,10 @@ class EmbyRepository(private val settings: SettingsStore) {
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
suspend fun setForYouMinutes(minutes: Int) = settings.setForYouMinutes(minutes)
suspend fun markForYouOpened() = settings.markForYouOpened()
// --- API instance caching (rebuilt only when the server URL changes) -----
private var cachedApi: EmbyApi? = null
@@ -133,7 +161,12 @@ 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, deviceName: String) {
suspend fun authenticate(
serverUrl: String,
username: String,
password: String,
deviceName: String,
): String {
clearPlayableCache()
settings.ensureDeviceId()
observedSettings = settings.snapshot() // pick up the freshly-generated device id
@@ -159,16 +192,17 @@ class EmbyRepository(private val settings: SettingsStore) {
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
"Gateway did not return a session"
}
val authenticatedUsername = result.username.ifBlank { username }
settings.saveSession(
gateway,
result.token,
result.userId,
result.username.ifBlank { username },
authenticatedUsername,
result.serverId.takeIf { it.isNotBlank() },
)
settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
return
return authenticatedUsername
}
val base = resolveServerUrl(ServerConfig.hardwiredUrl, serverUrl)
@@ -183,11 +217,30 @@ class EmbyRepository(private val settings: SettingsStore) {
settings.saveSession(base, token, userId, username, result.serverId)
settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
return username
}
suspend fun authPolicy(): GatewayAuthPolicy? =
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
/** True only when the gateway still recognises the token restored from storage. */
suspend fun validateSession(): Boolean {
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true
return runCatching { requireGateway().session() }.isSuccess
}
/**
* A 401 is authoritative: cached content must not masquerade as a slow connection.
* Remove only the rejected profile, then the root UI naturally returns to sign-in.
*/
suspend fun invalidateSession() {
settings.invalidateActiveSession()
observedSettings = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
}
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.
@@ -301,19 +354,19 @@ class EmbyRepository(private val settings: SettingsStore) {
/** Unfinished movies and episodes for the current Emby user. */
suspend fun getContinueWatching(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).continueWatching
return getHomeItems(
params = mapOf(
"Filters" to "IsResumable",
"IncludeItemTypes" to "Movie,Episode",
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getResumeItems(
userId,
mapOf(
"Recursive" to "true",
"SortBy" to "DatePlayed",
"SortOrder" to "Descending",
"MediaTypes" to "Video",
"Limit" to limit.toString(),
"Fields" to "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableUserData" to "true",
),
fields = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
).items
}
/** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */
@@ -351,8 +404,44 @@ class EmbyRepository(private val settings: SettingsStore) {
}
/** Library-wide search. Gateway-only: the direct path has no search UI behind it. */
suspend fun search(term: String, limit: Int = 40): List<BaseItem> =
requireGateway().search(term, limit).items
/**
* Library search, dual-path like everything else.
*
* The gateway answers from its imported Postgres copy (falling back to Emby before
* the first import finishes); direct mode asks Emby itself. Both return whatever
* relevance order the backend chose the caller re-ranks locally rather than
* re-sorting here, so the ordering rule stays a pure, testable function.
*/
suspend fun search(term: String, limit: Int = 40): List<BaseItem> {
val trimmed = term.trim()
if (trimmed.isEmpty()) return emptyList()
if (ServerConfig.isGateway) return requireGateway().search(trimmed, limit).items
return getHomeItems(
params = mapOf(
"SearchTerm" to trimmed,
"IncludeItemTypes" to "Movie,Series,Episode",
"Recursive" to "true",
"Limit" to limit.toString(),
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
}
/** Records a successful gateway search without affecting the direct Emby path. */
suspend fun recordSearch(term: String) {
if (ServerConfig.isGateway && term.trim().length >= 2) {
runCatching { requireGateway().recordSearch(mapOf("query" to term.trim())) }
}
}
/** Memby's database has no search history; this is a persisted Memby gateway feature. */
suspend fun getRecentSearches(): List<String> {
if (!ServerConfig.isGateway) return emptyList()
return runCatching { requireGateway().recentSearches().queries }
.getOrDefault(emptyList())
}
/**
* Recommendation rows on their own, forcing the gateway to build them synchronously
@@ -361,6 +450,12 @@ class EmbyRepository(private val settings: SettingsStore) {
*/
suspend fun getRecommendations(): List<HomeRow> = requireGateway().recommendations().rows
/** Tracearr-powered, request-scoped picks for the dedicated TV destination. */
suspend fun getForYou(availableMinutes: Int): List<HomeRow> {
if (!ServerConfig.isGateway) return emptyList()
return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows
}
/**
* The gateway's verdict on this build. Null on the direct path, where nobody is in a
* position to decide, and null on failure an unreachable gateway must never leave
@@ -383,10 +478,34 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireApi().getItem(
userId = userId,
itemId = itemId,
fields = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
)
}
/**
* All episodes for one show in a single request. Season switching is then a local
* list filter, keeping the detail screen immediate after its first load.
*/
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
if (seriesId.isBlank()) return emptyList()
if (ServerConfig.isGateway) {
return requireGateway().seriesEpisodes(seriesId).items
}
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getEpisodes(
seriesId,
mapOf(
"UserId" to userId,
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Thumb,Backdrop",
"ImageTypeLimit" to "1",
"Limit" to "1000",
),
).items
}
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
private suspend fun getHomeItems(
params: Map<String, String>,
@@ -466,6 +585,18 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
/**
* Optional decoration for the player pre-roll. Failure or direct-to-Emby mode returns
* an empty schedule immediately; playback never depends on this request.
*/
suspend fun prerollSchedule(): GatewayPrerollSchedule {
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) {
return GatewayPrerollSchedule()
}
return runCatching { requireGateway().prerollSchedule() }
.getOrDefault(GatewayPrerollSchedule())
}
/** Backdrop rotation interval, clamped to a sane range. */
fun rotationIntervalMillis(): Long =
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
@@ -498,6 +629,97 @@ class EmbyRepository(private val settings: SettingsStore) {
resolvePlayable(item)
}
/**
* Resolves a new URL for an item that was already selected for playback. This bypasses
* the launch cache: recovery must not hand the player the same potentially stale URL.
*
* Passing no type hint makes the gateway ask Emby for the authoritative item. That is
* one extra request only on recovery, and avoids accidentally treating a movie as an
* episode (or resolving a series a second time).
*/
suspend fun refreshPlayableStream(
itemId: String,
title: String,
resumePositionMs: Long,
): Playable {
require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
if (!ServerConfig.isGateway) {
val discovery = directPlayback(itemId, resumePositionMs)
return Playable(
itemId = itemId,
title = title,
url = buildStreamUrl(itemId),
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
)
}
val playback = requireGateway().playback(
itemId = itemId,
itemType = "",
title = title,
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { title },
url = playback.url,
// Recovery follows the local playhead. The server's persisted position may be
// up to one progress interval behind and would visibly jump the viewer back.
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
)
}
suspend fun selectEncodedSubtitle(
session: PlaybackSession,
subtitleIndex: Int,
title: String,
positionMs: Long,
): Playable {
if (ServerConfig.isGateway) {
val playback = requireGateway().playback(
itemId = session.itemId,
itemType = "",
title = title,
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitleIndex = subtitleIndex,
)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { title },
url = playback.url,
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
)
}
val discovery = directPlayback(
itemId = session.itemId,
positionMs = positionMs,
subtitleStreamIndex = subtitleIndex,
currentPlaySessionId = session.playSessionId,
)
return Playable(
itemId = session.itemId,
title = title,
url = discovery.url ?: buildStreamUrl(session.itemId),
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
)
}
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
@@ -539,6 +761,10 @@ class EmbyRepository(private val settings: SettingsStore) {
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = logoUrl(item),
subtitles = playback.subtitles,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
)
}
if (item.isSeries) {
@@ -549,20 +775,30 @@ class EmbyRepository(private val settings: SettingsStore) {
append(item.name)
episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") }
}
val discovery = directPlayback(episode.id, episode.resumePositionMs)
return Playable(
episode.id,
title,
buildStreamUrl(episode.id),
episode.resumePositionMs,
logoUrl(item),
discovery.subtitles,
discovery.mediaSourceId,
discovery.playSessionId,
discovery.playMethod,
)
}
val discovery = directPlayback(item.id, item.resumePositionMs)
return Playable(
item.id,
item.name,
buildStreamUrl(item.id),
item.resumePositionMs,
logoUrl(item),
discovery.subtitles,
discovery.mediaSourceId,
discovery.playSessionId,
discovery.playMethod,
)
}
@@ -574,43 +810,160 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) {
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
if (ServerConfig.isGateway) {
requireGateway().report("started", GatewayPlaybackReport(itemId, positionMs))
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
return
}
requireApi().reportPlaybackStarted(playbackReport(itemId, positionMs, isPaused = false))
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
}
suspend fun reportPlaybackProgress(itemId: String, positionMs: Long, isPaused: Boolean) {
suspend fun reportPlaybackProgress(
session: PlaybackSession,
positionMs: Long,
isPaused: Boolean,
eventName: String,
) {
if (ServerConfig.isGateway) {
requireGateway().report("progress", GatewayPlaybackReport(itemId, positionMs, isPaused))
requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName))
return
}
requireApi().reportPlaybackProgress(playbackReport(itemId, positionMs, isPaused))
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
}
suspend fun reportPlaybackStopped(itemId: String, positionMs: Long) {
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
try {
if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user,
// so Continue Watching reflects the new position on the next home load.
requireGateway().report("stopped", GatewayPlaybackReport(itemId, positionMs, isPaused = true))
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
} else {
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
}
} finally {
clearPlayableCache()
_playbackStops.tryEmit(itemId)
_playbackStops.tryEmit(session.itemId)
}
}
fun enqueuePlaybackStopped(itemId: String, positionMs: Long) {
fun enqueuePlaybackStopped(session: PlaybackSession, positionMs: Long) {
scope.launch {
runCatching { reportPlaybackStopped(itemId, positionMs) }
runCatching { reportPlaybackStopped(session, positionMs) }
}
}
/**
* The episode after [itemId], or null when nothing follows it a movie, a series
* finale, or simply a server that would not answer. "No next episode" is an ordinary
* outcome here, so failures are swallowed: the player just shows no banner.
*/
suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? {
if (itemId.isBlank()) return null
return runCatching {
if (ServerConfig.isGateway) gatewayNextEpisode(itemId, seriesId)
else directNextEpisode(itemId, seriesId)
}.getOrNull()
}
private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
return nextEpisodeOf(
response.item, response.url, response.resumePositionMs, response.subtitles,
response.mediaSourceId, response.playSessionId, response.playMethod,
)
}
private suspend fun directNextEpisode(itemId: String, seriesId: String?): NextEpisode? {
val userId = snapshot.userId ?: error("Not connected")
val series = seriesId?.takeIf { it.isNotBlank() }
?: getItemDetails(itemId).seriesId
?: return null
// AdjacentTo gives back [previous, current, next] in running order, minus whichever
// ends do not exist — so the current episode's position identifies the next one.
val episodes = requireApi().getEpisodes(
series,
mapOf(
"UserId" to userId,
"AdjacentTo" to itemId,
"Fields" to "RunTimeTicks,Overview,SeriesName",
"EnableUserData" to "true",
"EnableImageTypes" to "Primary,Thumb",
),
).items
val current = episodes.indexOfFirst { it.id == itemId }.takeIf { it >= 0 } ?: return null
val next = episodes.getOrNull(current + 1) ?: return null
val discovery = directPlayback(next.id, next.resumePositionMs)
return nextEpisodeOf(
next, buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
)
}
private fun nextEpisodeOf(
item: BaseItem,
url: String,
resumePositionMs: Long,
subtitles: List<PlayableSubtitle>,
mediaSourceId: String,
playSessionId: String,
playMethod: String,
) = NextEpisode(
itemId = item.id,
title = item.name,
seriesName = item.seriesName.orEmpty(),
episodeCode = item.episodeCode,
imageUrl = primaryUrl(item, maxWidth = 400),
url = url,
resumePositionMs = resumePositionMs,
subtitles = subtitles,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
playMethod = playMethod,
)
private suspend fun directPlayback(
itemId: String,
positionMs: Long,
subtitleStreamIndex: Int? = null,
currentPlaySessionId: String? = null,
): PlaybackDiscovery {
val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
val token = snapshot.token.orEmpty()
return runCatching {
val info = requireApi().getPlaybackInfo(
itemId,
userId,
body = PlaybackInfoRequest(
id = itemId,
userId = userId,
startTimeTicks = millisecondsToTicks(positionMs),
subtitleStreamIndex = subtitleStreamIndex,
currentPlaySessionId = currentPlaySessionId,
),
)
info.mediaSources.firstOrNull()?.let { source ->
PlaybackDiscovery(
subtitles = subtitleTracks(
streams = source.mediaStreams,
serverUrl = serverUrl,
token = token,
itemId = itemId,
mediaSourceId = source.id,
),
mediaSourceId = source.id.ifBlank { itemId },
playSessionId = info.playSessionId,
playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) {
"Transcode"
} else "DirectPlay",
url = source.transcodingUrl
?.takeIf { subtitleStreamIndex != null }
?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
)
} ?: PlaybackDiscovery(mediaSourceId = itemId)
}.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) }
}
private suspend fun firstNextUpEpisode(userId: String, seriesId: String): BaseItem? =
runCatching {
requireApi().getNextUp(
@@ -671,6 +1024,20 @@ class EmbyRepository(private val settings: SettingsStore) {
return imageUrl(item.id, "Primary", tag, maxWidth)
}
/**
* Poster for an item the app never received as a [BaseItem] a service alert names
* its subject by id and tag only, and the Sonarr ids it carries resolve through the
* gateway's image proxy like any other.
*/
fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
if (itemId.isBlank() || tag.isBlank()) return null
return imageUrl(itemId, "Primary", tag, maxWidth)
}
/** Emby stores cast portraits as the person's Primary image. */
fun personImageUrl(person: com.ponzischeme89.memby.data.model.EmbyPerson, maxWidth: Int = 240): String? =
posterUrl(person.id, person.primaryImageTag.orEmpty(), maxWidth)
/**
* Builds an artwork URL for whichever backend this build uses.
*
@@ -725,16 +1092,51 @@ class EmbyRepository(private val settings: SettingsStore) {
"&DeviceId=${encode(deviceId)}"
}
private fun playbackReport(itemId: String, positionMs: Long, isPaused: Boolean) =
private fun playbackReport(
session: PlaybackSession,
positionMs: Long,
isPaused: Boolean,
eventName: String?,
) =
PlaybackReport(
itemId = itemId,
itemId = session.itemId,
mediaSourceId = session.mediaSourceId,
playSessionId = session.playSessionId,
positionTicks = millisecondsToTicks(positionMs),
isPaused = isPaused,
playMethod = session.playMethod,
eventName = eventName,
)
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
}
data class PlaybackSession(
val itemId: String,
val mediaSourceId: String,
val playSessionId: String,
val playMethod: String = "DirectPlay",
)
private data class PlaybackDiscovery(
val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
val url: String? = null,
)
private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, eventName: String?) =
GatewayPlaybackReport(
itemId = itemId,
positionMs = positionMs,
isPaused = isPaused,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
playMethod = playMethod,
eventName = eventName,
)
private data class CachedPlayable(
val playable: Playable,
val expiresAtMs: Long,
@@ -754,11 +1156,13 @@ class DeviceLimitException(
val maxClients: Int,
) : Exception("Memby device allowance reached")
/** Shared across the error-body parsers below; building a Json format per call is costly. */
private val errorBodyJson = Json { ignoreUnknownKeys = true }
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
if (body.isBlank()) return null
return runCatching {
val parsed = Json { ignoreUnknownKeys = true }
.decodeFromString<GatewayAuthError>(body)
val parsed = errorBodyJson.decodeFromString<GatewayAuthError>(body)
parsed.takeIf {
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
}?.let {
@@ -802,8 +1206,7 @@ private fun maintenanceMessage(t: HttpException): String? = runCatching {
internal fun parseMaintenanceMessage(body: String): String? {
if (body.isBlank()) return null
return runCatching {
val parsed = Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceResponse>(body)
val parsed = errorBodyJson.decodeFromString<MaintenanceResponse>(body)
parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160)
}.getOrNull()
}
@@ -811,6 +1214,9 @@ internal fun parseMaintenanceMessage(body: String): String? {
/** True when this failure is the gateway reporting a deliberate outage. */
fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503
/** True when the gateway has rejected the persisted session token. */
fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401
@Serializable
private data class MaintenanceResponse(
val maintenance: Boolean = false,
@@ -44,12 +44,18 @@ data class Settings(
val updateToken: String? = null,
// Show each item's Emby "Logo" image in place of the plain-text title.
val showTitleLogo: Boolean = true,
// Slide up a "next up" banner near the end of an episode and roll into the next one.
val autoPlayNextEpisode: Boolean = true,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
/** Comma-separated, user-controlled order of rows shown on the client home. */
val homeSections: String = DEFAULT_HOME_SECTIONS,
val homeCacheJson: String? = null,
/** Last duration chosen in For You, scoped to the active Emby profile on this TV. */
val forYouMinutes: Int = 0,
/** Whether this profile has discovered the dedicated For You destination. */
val hasOpenedForYou: Boolean = false,
val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
val showHomeCardMetadata: Boolean = true,
val profiles: List<EmbyProfile> = emptyList(),
@@ -80,6 +86,8 @@ data class EmbyProfile(
val username: String,
val serverId: String? = null,
val homeCacheJson: String? = null,
val forYouMinutes: Int = 0,
val hasOpenedForYou: Boolean = false,
)
class SettingsStore(private val context: Context) {
@@ -91,6 +99,7 @@ class SettingsStore(private val context: Context) {
get() = latestSettings
private object Keys {
const val MAX_SEEN_ALERTS = 40
val SERVER_URL = stringPreferencesKey("server_url")
val TOKEN = stringPreferencesKey("token")
val USER_ID = stringPreferencesKey("user_id")
@@ -103,13 +112,17 @@ class SettingsStore(private val context: Context) {
val UPDATE_REPO = stringPreferencesKey("update_repo")
val UPDATE_TOKEN = stringPreferencesKey("update_token")
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
val HOME_CACHE = stringPreferencesKey("home_cache")
val FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes")
val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
val PROFILES = stringPreferencesKey("profiles")
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
}
/**
@@ -145,10 +158,37 @@ class SettingsStore(private val context: Context) {
}
}
/**
* Ids of alerts already shown on this TV. The gateway keeps offering an alert for as
* long as it is current, so without this an "aired" banner would return every poll
* and again after every relaunch. Only the most recent ids are kept; older ones have
* long since fallen out of the server's window.
*/
suspend fun seenAlertIds(): Set<String> =
decodeAlertIds(context.dataStore.data.first()[Keys.SEEN_ALERTS]).toSet()
suspend fun markAlertSeen(id: String) {
val trimmed = id.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
val ids = decodeAlertIds(preferences[Keys.SEEN_ALERTS]).filterNot { it == trimmed }
preferences[Keys.SEEN_ALERTS] = (ids + trimmed)
.takeLast(Keys.MAX_SEEN_ALERTS)
.joinToString("\n")
}
}
private fun decodeAlertIds(raw: String?): List<String> =
raw?.split('\n')?.filter { it.isNotBlank() }.orEmpty()
suspend fun setShowTitleLogo(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled }
}
suspend fun setAutoPlayNextEpisode(enabled: Boolean) {
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
}
suspend fun setRingColor(hex: String) {
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
}
@@ -194,6 +234,39 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setForYouMinutes(minutes: Int) {
val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
context.dataStore.edit { preferences ->
preferences[Keys.FOR_YOU_MINUTES] = selected
updateActiveProfile(preferences) { it.copy(forYouMinutes = selected) }
}
}
suspend fun markForYouOpened() {
context.dataStore.edit { preferences ->
preferences[Keys.HAS_OPENED_FOR_YOU] = true
updateActiveProfile(preferences) { it.copy(hasOpenedForYou = true) }
}
}
private fun updateActiveProfile(
preferences: MutablePreferences,
transform: (EmbyProfile) -> EmbyProfile,
) {
val activeUserId = preferences[Keys.USER_ID]
val activeServer = preferences[Keys.SERVER_URL]
val profiles = profilesFrom(preferences).map { profile ->
if (profile.userId == activeUserId && profile.serverUrl == activeServer) {
transform(profile)
} else {
profile
}
}
if (profiles.isNotEmpty()) {
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
}
}
fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let {
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
}
@@ -227,6 +300,8 @@ class SettingsStore(private val context: Context) {
username = username,
serverId = serverId,
homeCacheJson = previous?.homeCacheJson,
forYouMinutes = previous?.forYouMinutes ?: 0,
hasOpenedForYou = previous?.hasOpenedForYou ?: false,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
@@ -248,17 +323,40 @@ class SettingsStore(private val context: Context) {
suspend fun clearSession() {
context.dataStore.edit {
it.remove(Keys.SERVER_URL)
it.remove(Keys.TOKEN)
it.remove(Keys.USER_ID)
it.remove(Keys.SERVER_ID)
it.remove(Keys.LAST_BACKDROP_URL)
it.remove(Keys.HOME_CACHE)
it.remove(Keys.USERNAME)
clearActiveSession(it)
// Intentionally keep DEVICE_ID stable across sign-outs.
}
}
/**
* Drops a server-rejected profile as well as the active session. Keeping the stale
* profile would let the profile picker immediately restore the same dead token and
* trap the viewer in a 401 loop.
*/
suspend fun invalidateActiveSession() {
context.dataStore.edit { preferences ->
val activeUserId = preferences[Keys.USER_ID]
val activeServer = preferences[Keys.SERVER_URL]
val remaining = profilesFrom(preferences).filterNot {
it.userId == activeUserId && it.serverUrl == activeServer
}
preferences[Keys.PROFILES] = Json.encodeToString(remaining)
clearActiveSession(preferences)
}
}
private fun clearActiveSession(preferences: MutablePreferences) {
preferences.remove(Keys.SERVER_URL)
preferences.remove(Keys.TOKEN)
preferences.remove(Keys.USER_ID)
preferences.remove(Keys.SERVER_ID)
preferences.remove(Keys.LAST_BACKDROP_URL)
preferences.remove(Keys.HOME_CACHE)
preferences.remove(Keys.FOR_YOU_MINUTES)
preferences.remove(Keys.HAS_OPENED_FOR_YOU)
preferences.remove(Keys.USERNAME)
}
private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
preferences[Keys.SERVER_URL] = profile.serverUrl
preferences[Keys.TOKEN] = profile.token
@@ -268,6 +366,8 @@ class SettingsStore(private val context: Context) {
else preferences[Keys.SERVER_ID] = profile.serverId
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
else preferences[Keys.HOME_CACHE] = profile.homeCacheJson
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
preferences.remove(Keys.LAST_BACKDROP_URL)
}
@@ -284,6 +384,8 @@ class SettingsStore(private val context: Context) {
username = username,
serverId = preferences[Keys.SERVER_ID],
homeCacheJson = preferences[Keys.HOME_CACHE],
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
)
}
@@ -310,10 +412,13 @@ class SettingsStore(private val context: Context) {
updateRepo = preferences[Keys.UPDATE_REPO],
updateToken = preferences[Keys.UPDATE_TOKEN],
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: 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],
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
profiles = profiles,
@@ -38,11 +38,84 @@ data class UserItemData(
@Serializable
data class PlaybackReport(
@SerialName("ItemId") val itemId: String,
@SerialName("MediaSourceId") val mediaSourceId: String,
@SerialName("PlaySessionId") val playSessionId: String,
@SerialName("PositionTicks") val positionTicks: Long = 0,
@SerialName("IsPaused") val isPaused: Boolean = false,
@SerialName("IsMuted") val isMuted: Boolean = false,
@SerialName("CanSeek") val canSeek: Boolean = true,
@SerialName("PlayMethod") val playMethod: String = "DirectPlay",
@SerialName("EventName") val eventName: String? = null,
)
@Serializable
data class PlaybackInfoRequest(
@SerialName("Id") val id: String,
@SerialName("UserId") val userId: String,
@SerialName("IsPlayback") val isPlayback: Boolean = true,
@SerialName("StartTimeTicks") val startTimeTicks: Long = 0,
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
@SerialName("DeviceProfile") val deviceProfile: DeviceProfile = DeviceProfile.embyAndroidTv(),
)
@Serializable
data class DeviceProfile(
@SerialName("Name") val name: String,
@SerialName("SupportedMediaTypes") val supportedMediaTypes: String = "Video",
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
) {
companion object {
fun embyAndroidTv() = DeviceProfile(
name = "Memby Android TV",
subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
).map { SubtitleProfile(it, "External") } + listOf(
"pgs", "pgssub", "sup", "vobsub", "dvdsub", "dvbsub",
).map { SubtitleProfile(it, "Encode") },
directPlayProfiles = listOf(
DirectPlayProfile(
container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
),
),
transcodingProfiles = listOf(
TranscodingProfile(
container = "ts",
videoCodec = "h264",
audioCodec = "aac",
protocol = "hls",
),
),
)
}
}
@Serializable
data class DirectPlayProfile(
@SerialName("Container") val container: String,
@SerialName("VideoCodec") val videoCodec: String,
@SerialName("AudioCodec") val audioCodec: String,
@SerialName("Type") val type: String = "Video",
)
@Serializable
data class TranscodingProfile(
@SerialName("Container") val container: String,
@SerialName("VideoCodec") val videoCodec: String,
@SerialName("AudioCodec") val audioCodec: String,
@SerialName("Protocol") val protocol: String,
@SerialName("Type") val type: String = "Video",
@SerialName("Context") val context: String = "Streaming",
)
@Serializable
data class SubtitleProfile(
@SerialName("Format") val format: String,
@SerialName("Method") val method: String,
)
@Serializable
@@ -52,9 +125,20 @@ data class Studio(
@Serializable
data class MediaStream(
@SerialName("Index") val index: Int = -1,
@SerialName("Type") val type: String = "",
@SerialName("Codec") val codec: String? = null,
@SerialName("Title") val title: String? = null,
@SerialName("DisplayTitle") val displayTitle: String? = null,
@SerialName("Language") val language: String? = null,
@SerialName("IsDefault") val isDefault: Boolean = false,
@SerialName("IsForced") val isForced: Boolean = false,
@SerialName("IsHearingImpaired") val isHearingImpaired: Boolean = false,
@SerialName("IsExternal") val isExternal: Boolean = false,
@SerialName("IsTextSubtitleStream") val isTextSubtitleStream: Boolean = false,
@SerialName("SupportsExternalStream") val supportsExternalStream: Boolean = false,
@SerialName("DeliveryUrl") val deliveryUrl: String? = null,
@SerialName("DeliveryMethod") val deliveryMethod: String? = null,
@SerialName("Width") val width: Int? = null,
@SerialName("Height") val height: Int? = null,
@SerialName("VideoRange") val videoRange: String? = null,
@@ -62,6 +146,31 @@ data class MediaStream(
@SerialName("Channels") val channels: Int? = null,
)
@Serializable
data class PlaybackInfo(
@SerialName("MediaSources") val mediaSources: List<MediaSourceInfo> = emptyList(),
@SerialName("PlaySessionId") val playSessionId: String = "",
)
@Serializable
data class MediaSourceInfo(
@SerialName("Id") val id: String = "",
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("DirectStreamUrl") val directStreamUrl: String? = null,
@SerialName("TranscodingUrl") val transcodingUrl: String? = null,
)
@Serializable
data class EmbyPerson(
@SerialName("Id") val id: String = "",
@SerialName("Name") val name: String = "",
@SerialName("Role") val role: String? = null,
@SerialName("Type") val type: String = "",
@SerialName("PrimaryImageTag") val primaryImageTag: String? = null,
) {
val isCastMember: Boolean get() = type.equals("Actor", ignoreCase = true)
}
@Serializable
data class BaseItem(
@SerialName("Id") val id: String,
@@ -76,11 +185,15 @@ data class BaseItem(
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
@SerialName("Genres") val genres: List<String> = emptyList(),
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
@SerialName("BackdropImageTags") val backdropImageTags: List<String> = emptyList(),
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
// Emby returns these on episodes without being asked, so they cost no extra Fields.
@SerialName("IndexNumber") val indexNumber: Int? = null,
@SerialName("ParentIndexNumber") val parentIndexNumber: Int? = null,
@SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null,
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(),
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@@ -96,12 +209,26 @@ data class BaseItem(
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// Derived by the TV from the Sonarr schedule row and retained in the local home cache.
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
// Explainability supplied only by the gateway's dedicated For You endpoint.
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
@SerialName("MembyCompatibility") val membyCompatibility: String? = null,
) {
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"
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
val episodeCode: String?
get() {
val episode = indexNumber ?: return null
val season = parentIndexNumber
return if (season != null) "S$season · E$episode" else "E$episode"
}
/** Runtime in whole minutes, or null when unknown. */
val runtimeMinutes: Int?
@@ -104,6 +104,29 @@ data class GatewayUpdate(
data class GatewayServiceStatus(
val maintenance: Boolean = false,
val message: String = "",
val alerts: List<GatewayAlert> = emptyList(),
val compatible: Boolean = true,
val compatibilityMessage: String = "",
val clientVersion: String = "",
val clientProtocol: String = "",
val serverProtocol: Int = 0,
)
/**
* An informational nudge riding along on the status poll currently "this episode aired
* and is on its way into Emby". It is never actionable: the banner slides in, states the
* news and leaves. Unknown [kind] values still render, so the server can add one without
* an app release.
*/
@Serializable
data class GatewayAlert(
val id: String = "",
val kind: String = "",
val title: String = "",
val message: String = "",
val itemId: String = "",
val imageTag: String = "",
val airedAt: String = "",
)
/** Response of `GET /v1/recommendations`. */
@@ -117,12 +140,52 @@ data class GatewayItems(
val items: List<BaseItem> = emptyList(),
)
@Serializable
data class GatewaySearchHistory(
val queries: List<String> = emptyList(),
)
@Serializable
data class GatewayPrerollSchedule(
val today: List<GatewayPrerollEntry> = emptyList(),
val thisWeek: List<GatewayPrerollEntry> = emptyList(),
)
@Serializable
data class GatewayPrerollEntry(
val series: String = "",
val episode: String = "",
val episodeCode: String = "",
val schedule: String = "",
val availability: String = "",
)
@Serializable
data class GatewayPlayback(
val itemId: String,
val title: String = "",
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
/**
* The episode that follows the one being watched. [item] is Emby's item JSON forwarded
* verbatim, so it decodes into the same [BaseItem] used everywhere else.
*/
@Serializable
data class GatewayNextEpisode(
val item: BaseItem,
val title: String = "",
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
@Serializable
@@ -151,4 +214,8 @@ data class GatewayPlaybackReport(
val itemId: String,
val positionMs: Long,
val isPaused: Boolean = false,
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
val eventName: String? = null,
)
@@ -4,6 +4,8 @@ import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.AuthResult
import com.ponzischeme89.memby.data.model.ItemsResult
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.PlaybackInfo
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body
import retrofit2.http.DELETE
@@ -31,6 +33,20 @@ interface EmbyApi {
@Query("Fields") fields: String,
): com.ponzischeme89.memby.data.model.BaseItem
@POST("Items/{itemId}/PlaybackInfo")
suspend fun getPlaybackInfo(
@Path("itemId") itemId: String,
@Query("UserId") userId: String,
@Query("IsPlayback") isPlayback: Boolean = true,
@Body body: PlaybackInfoRequest,
): PlaybackInfo
@GET("Users/{userId}/Items/Resume")
suspend fun getResumeItems(
@Path("userId") userId: String,
@QueryMap params: Map<String, String>,
): ItemsResult
@GET("Users/{userId}/Items/{itemId}/LocalTrailers")
suspend fun getLocalTrailers(
@Path("userId") userId: String,
@@ -7,10 +7,13 @@ import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayItems
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.UserItemData
@@ -38,6 +41,10 @@ interface GatewayApi {
@POST("v1/auth/logout")
suspend fun logout()
/** Confirms that a token restored from TV storage still exists on the gateway. */
@GET("v1/auth/session")
suspend fun session(): GatewayLoginResponse
@GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome
@@ -47,10 +54,22 @@ interface GatewayApi {
@GET("v1/search")
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
@POST("v1/search/history")
suspend fun recordSearch(@Body body: Map<String, String>)
@GET("v1/search/history")
suspend fun recentSearches(): GatewaySearchHistory
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
@GET("v1/recommendations")
suspend fun recommendations(): GatewayRows
@GET("v1/for-you")
suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows
@GET("v1/preroll")
suspend fun prerollSchedule(): GatewayPrerollSchedule
/**
* Whether this build should update. The version travels as a header on every request
* (see GatewayServiceFactory), so there is nothing to pass here.
@@ -65,14 +84,26 @@ interface GatewayApi {
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
/** All episodes for a series in display order; the client groups them into seasons. */
@GET("v1/items/{id}/episodes")
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
@GET("v1/items/{id}/playback")
suspend fun playback(
@Path("id") itemId: String,
@Query("type") itemType: String,
@Query("title") title: String,
@Query("resumePositionMs") resumePositionMs: Long,
@Query("subtitleIndex") subtitleIndex: Int? = null,
): GatewayPlayback
/** 404 when nothing follows this item: a movie, or a series finale. */
@GET("v1/items/{id}/next")
suspend fun nextEpisode(
@Path("id") itemId: String,
@Query("seriesId") seriesId: String,
): GatewayNextEpisode
@GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem
@@ -51,9 +51,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
// The gateway decides whether this build needs updating, so every request
// says which build it is.
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it")
}
return chain.proceed(builder.build())
}
}
internal const val MEMBY_PROTOCOL_VERSION = 1
@@ -3,12 +3,14 @@ package com.ponzischeme89.memby.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,14 +28,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -44,17 +48,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -76,12 +81,14 @@ 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.FavoriteBorder
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.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
@@ -95,21 +102,27 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val EmbyGreen = Color(0xFF52B54B)
private val RailSurface = Color(0xF20C0F12)
private val MutedText = Color(0xFFB7BDC3)
private val QuietText = Color(0xFF8C949B)
// High-contrast neutrals tuned for the app's near-black surfaces. Quiet remains visibly
// secondary from TV distance without falling into low-contrast grey-on-black.
private val MutedText = Color(0xFFD0D6DB)
private val QuietText = Color(0xFFAEB7BF)
internal val TvRailCollapsedWidth = 54.dp
internal val TvRailExpandedWidth = 184.dp
internal val TvRailContentShift = 112.dp
enum class BrowseDestination(val label: String, val icon: ImageVector) {
HOME("Home", Icons.Default.Home),
FOR_YOU("For You", Icons.Default.AutoAwesome),
SEARCH("Search", Icons.Default.Search),
MOVIES("Movies", Icons.Default.Movie),
SHOWS("TV Shows", Icons.Default.Tv),
FAVORITES("Favourites", Icons.Default.Favorite),
@@ -131,53 +144,44 @@ data class HomeBrowseRow(
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)),
)
row.id.startsWith("for-you:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
)
else -> HomeRowVisual(
Icons.Default.VideoLibrary,
listOf(Color(0xFF536976), Color(0xFF738A96)),
)
}
@@ -186,12 +190,34 @@ fun TvNavigationRail(
selected: BrowseDestination,
expanded: Boolean,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onRailFocusChanged: (Boolean) -> Unit,
onDestinationSelected: (BrowseDestination) -> Unit,
modifier: Modifier = Modifier,
) {
val railWidth by animateDpAsState(
var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) }
val logoAlpha = remember { Animatable(0f) }
val logoRotation = remember { Animatable(0f) }
LaunchedEffect(Unit) {
kotlinx.coroutines.coroutineScope {
launch { logoScale.animateTo(1f, tween(620)) }
launch { logoAlpha.animateTo(1f, tween(420)) }
}
}
LaunchedEffect(selected) {
if (selected == BrowseDestination.HOME) {
logoRotation.snapTo(0f)
logoRotation.animateTo(360f, tween(850))
}
}
LaunchedEffect(railHasFocus) {
if (railHasFocus) {
// Spatial focus may initially choose the first rail item (Home). Correct that
// only as focus enters the rail; subsequent up/down movement must remain free.
runCatching { navigationFocusRequester.requestFocus() }
}
}
val railWidth = animateDpAsState(
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
animationSpec = tween(160),
label = "navigation-rail-width",
@@ -207,10 +233,30 @@ fun TvNavigationRail(
// The parent deliberately reports only the collapsed footprint to the
// home Row. requiredWidth lets the focused surface draw outward without
// remeasuring gallery cards or clipping their labels to 54dp.
.wrapContentSize(Alignment.TopStart, unbounded = true)
.requiredWidth(railWidth)
// Only width may escape the collapsed 54dp footprint. Unbounding height
// here would make fillMaxHeight lose the screen constraint and stop the
// rail surface at its final child (the version label).
.wrapContentWidth(Alignment.Start, unbounded = true)
// Read in the measure lambda, not the modifier chain: expanding the rail
// is the most frequent animation in the app, and reading the animated Dp
// during composition recomposes the rail and every item in it per frame.
.layout { measurable, constraints ->
val width = railWidth.value.roundToPx()
val placeable = measurable.measure(
constraints.copy(minWidth = width, maxWidth = width),
)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}
.fillMaxHeight()
.background(RailSurface)
.onFocusChanged { focusState ->
val hasFocus = focusState.hasFocus
if (railHasFocus != hasFocus) {
railHasFocus = hasFocus
onRailFocusChanged(hasFocus)
}
}
.focusGroup()
.padding(horizontal = 5.dp, vertical = 15.dp),
horizontalAlignment = Alignment.Start,
) {
@@ -222,7 +268,15 @@ fun TvNavigationRail(
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier.width(32.dp).height(27.dp),
modifier = Modifier
.width(32.dp)
.height(27.dp)
.graphicsLayer {
scaleX = logoScale.value
scaleY = logoScale.value
alpha = logoAlpha.value
rotationZ = logoRotation.value
},
)
if (expanded) {
Column(verticalArrangement = Arrangement.Center) {
@@ -251,12 +305,20 @@ fun TvNavigationRail(
selected = destination == selected,
expanded = expanded,
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
contentFocusRequester = contentFocusRequester,
onFocused = { onRailFocusChanged(true) },
onFocused = {},
onClick = { onDestinationSelected(destination) },
)
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.weight(1f))
Text(
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
color = QuietText,
fontSize = if (expanded) 10.sp else 8.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
)
}
}
}
@@ -266,13 +328,14 @@ fun ExpandableNavigationItem(
destination: BrowseDestination,
selected: Boolean,
expanded: Boolean,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val background by animateColorAsState(
// Not `by`: both colours are read in the draw phase / at the point of use, so the
// 85ms focus transition repaints this row rather than recomposing it.
val background = animateColorAsState(
targetValue = when {
focused -> Color.White.copy(alpha = 0.13f)
selected -> EmbyGreen.copy(alpha = 0.10f)
@@ -281,7 +344,7 @@ fun ExpandableNavigationItem(
animationSpec = tween(85),
label = "navigation-item-background",
)
val foreground by animateColorAsState(
val foreground = animateColorAsState(
targetValue = when {
focused -> Color.White
selected -> EmbyGreen
@@ -294,13 +357,12 @@ fun ExpandableNavigationItem(
modifier = modifier
.fillMaxWidth()
.height(44.dp)
.focusProperties { right = contentFocusRequester }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clip(RoundedCornerShape(8.dp))
.background(background)
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics { contentDescription = destination.label }
.padding(horizontal = 8.dp),
@@ -308,7 +370,7 @@ fun ExpandableNavigationItem(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
Icon(destination.icon, contentDescription = null, tint = foreground, modifier = Modifier.size(21.dp))
Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
if (selected) {
Box(
Modifier
@@ -322,7 +384,7 @@ fun ExpandableNavigationItem(
if (expanded) {
Text(
destination.label,
color = foreground,
color = foreground.value,
fontSize = 15.sp,
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
@@ -390,7 +452,6 @@ fun MediaMetadataPanel(
item: BaseItem?,
loading: Boolean,
sectionLabel: String,
contentFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester,
onPlay: (BaseItem) -> Unit,
onContentFocused: () -> Unit,
@@ -414,7 +475,6 @@ fun MediaMetadataPanel(
onClick = { item?.let(onPlay) },
enabled = item != null,
modifier = Modifier
.focusRequester(contentFocusRequester)
.focusProperties { left = navigationFocusRequester }
.onFocusChanged { if (it.isFocused) onContentFocused() },
) {
@@ -477,7 +537,7 @@ fun MediaDetailsOverlay(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.68f)
.padding(start = 72.dp, end = 36.dp, top = 60.dp, bottom = 48.dp),
.padding(start = 72.dp, end = 36.dp, top = 38.dp, bottom = 32.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
@@ -517,16 +577,24 @@ fun MediaDetailsOverlay(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.membyAiringToday) {
Spacer(Modifier.height(8.dp))
MediaBadge("AIRING TODAY")
}
Spacer(Modifier.height(16.dp))
Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = Color(0xFFD8DCDF),
fontSize = 17.sp,
lineHeight = 23.sp,
maxLines = 4,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(22.dp))
if (item.cast.isNotEmpty()) {
Spacer(Modifier.height(14.dp))
CastRail(item.cast, compact = true)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { onPlay(item) },
@@ -636,7 +704,21 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) {
if (facts.isNotEmpty()) {
Text(facts.joinToString(""), color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val badges = mediaBadges(item)
val badges = buildList {
if (item.membyAiringToday) add("AIRING TODAY")
addAll(mediaBadges(item))
}
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
Text(
text = it,
color = EmbyGreen,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.78f),
)
}
if (badges.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
badges.forEach { MediaBadge(it) }
@@ -785,6 +867,11 @@ fun MediaRow(
) {
val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val scope = rememberCoroutineScope()
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
// only the button's enabled/disabled flip recomposes the row header.
val canScrollBack by remember(rowState) {
derivedStateOf { rowState.firstVisibleItemIndex > 0 }
}
val pageSize = if (
row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT
) 6 else 4
@@ -798,8 +885,8 @@ fun MediaRow(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Brush.linearGradient(visual.colors))
.border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape),
.background(Color.White.copy(alpha = 0.08f))
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
@@ -814,13 +901,13 @@ fun MediaRow(
row.title,
color = Color(0xFFF1F3F4),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
if (row.items.isNotEmpty()) {
GalleryJumpButton(
forward = false,
enabled = rowState.firstVisibleItemIndex > 0,
enabled = canScrollBack,
onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
scope.launch { rowState.scrollToItem(target) }
@@ -852,6 +939,12 @@ fun MediaRow(
}
}
}
row.items.isEmpty() && row.id == "favourite-shows" -> {
FavoriteShowsEmptyState(
navigationFocusRequester = navigationFocusRequester,
onContentFocused = onContentFocused,
)
}
row.items.isEmpty() -> {
Text(
row.emptyMessage,
@@ -865,7 +958,11 @@ fun MediaRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().focusGroup().focusRestorer(),
// focusRestorer pins a lazy item. Replacing a For You result set
// can dispose that item during a focus transfer and make Compose
// release the same pin twice. LazyListState already preserves the
// row position; native TV spatial search safely handles row changes.
modifier = Modifier.fillMaxWidth().focusGroup(),
) {
itemsIndexed(
row.items,
@@ -874,7 +971,6 @@ fun MediaRow(
) { index, item ->
var cardModifier: Modifier = Modifier
if (index == 0) {
cardModifier = cardModifier.focusProperties { left = navigationFocusRequester }
if (contentEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
}
@@ -910,6 +1006,172 @@ fun MediaRow(
}
}
@Composable
internal fun CastRail(
people: List<EmbyPerson>,
modifier: Modifier = Modifier,
compact: Boolean = false,
showTitle: Boolean = true,
) {
val cast = remember(people) { people.filter(EmbyPerson::isCastMember).take(16) }
if (cast.isEmpty()) return
Column(modifier) {
if (showTitle) {
Text(
"Cast",
color = Color.White,
fontSize = if (compact) 16.sp else 21.sp,
fontWeight = FontWeight.SemiBold,
)
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 16.dp),
contentPadding = PaddingValues(
top = if (showTitle) 7.dp else 3.dp,
end = 24.dp,
bottom = 4.dp,
),
) {
items(cast, key = { "${it.id}:${it.name}" }) { person ->
CastCard(person, compact)
}
}
}
}
@Composable
private fun CastCard(person: EmbyPerson, compact: Boolean) {
val repository = ServiceLocator.repository
val portrait = remember(person.id, person.primaryImageTag) {
repository.personImageUrl(person, if (compact) 180 else 280)
}
var focused by remember { mutableStateOf(false) }
val width = if (compact) 72.dp else 112.dp
val height = if (compact) 76.dp else 142.dp
val shape = RoundedCornerShape(if (compact) 8.dp else 10.dp)
val scale by animateFloatAsState(
targetValue = if (focused) 1.045f else 1f,
animationSpec = tween(110),
label = "cast-card-focus",
)
Column(
Modifier
.width(width)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged { focused = it.isFocused }
.focusable(),
) {
Box(
Modifier
.fillMaxWidth()
.height(height)
.clip(shape)
.background(Color(0xFF24292E))
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape,
),
contentAlignment = Alignment.Center,
) {
if (portrait != null) {
AsyncImage(
model = portrait,
contentDescription = person.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = QuietText,
modifier = Modifier.size(if (compact) 28.dp else 40.dp),
)
}
}
Text(
person.name,
color = Color.White,
fontSize = if (compact) 11.sp else 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 5.dp),
)
person.role?.takeIf(String::isNotBlank)?.let { role ->
Text(
role,
color = QuietText,
fontSize = if (compact) 9.sp else 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun FavoriteShowsEmptyState(
navigationFocusRequester: FocusRequester,
onContentFocused: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 36.dp, vertical = 10.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = if (focused) 0.11f else 0.055f))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.14f),
RoundedCornerShape(14.dp),
)
.focusProperties { left = navigationFocusRequester }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onContentFocused()
}
.focusable()
.padding(horizontal = 22.dp, vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.09f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.FavoriteBorder,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(25.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"No favorite shows yet",
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Mark a series as a favorite and itll be waiting here.",
color = MutedText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
private fun GalleryJumpButton(
forward: Boolean,
@@ -971,6 +1233,28 @@ fun PortraitMediaCard(
)
}
/**
* A poster card at an explicit width, for grids rather than rows.
*
* [PortraitMediaCard] derives its width from the row's available width, which is the
* wrong input for a grid whose column count is already decided. Same card underneath
* same artwork loading, badges and focus treatment.
*/
@Composable
fun PosterGridCard(
item: BaseItem,
width: Dp,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
showSecondaryMetadata = true, onFocused, onClick, onLongClick, modifier,
)
}
@Composable
fun LandscapeMediaCard(
item: BaseItem,
@@ -1138,12 +1422,18 @@ private fun MediaCard(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
if (item.membyAiringToday) {
MediaBadge(
"AIRING TODAY",
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
Text(
item.name,
color = if (focused) Color.White else Color(0xFFD1D5D8),
color = if (focused) Color.White else Color(0xFFE1E5E8),
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
@@ -1153,6 +1443,7 @@ private fun MediaCard(
cardSubtitle(item, showProgress, position),
color = QuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
@@ -1190,14 +1481,14 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
fun FocusScaleContainer(
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
contentDescription: String,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
content: @Composable BoxScope.(focused: Boolean) -> Unit,
) {
var focused by remember { mutableStateOf(false) }
var remoteLongPressHandled by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
val scale = animateFloatAsState(
targetValue = if (focused) 1.025f else 1f,
animationSpec = tween(95),
label = "media-card-focus",
@@ -1205,16 +1496,13 @@ fun FocusScaleContainer(
Box(
modifier = modifier
.zIndex(if (focused) 1f else 0f)
.then(
if (focused || scale != 1f) {
Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
}
} else {
Modifier
},
)
// Always a graphicsLayer, and the scale read inside it. Branching on
// `scale != 1f` meant reading the animated value during composition, so
// every card recomposed on each frame of its own focus animation.
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
@@ -1309,6 +1597,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.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
item.isEpisode -> item.seriesName ?: "Up next"
item.productionYear != null && item.runtimeMinutes != null ->
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.analytics.RowAnalytics
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError
@@ -83,12 +84,21 @@ data class HomeUiState(
}
}
data class ForYouUiState(
val rows: List<HomeRow> = emptyList(),
val availableMinutes: Int = 0,
val loading: Boolean = false,
val error: String? = null,
)
class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val refreshMutex = Mutex()
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
val state: StateFlow<HomeUiState> = _state.asStateFlow()
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
@@ -163,6 +173,31 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
repository.reportRowEvents(analytics.drain())
}
fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) {
val minutes = availableMinutes.coerceIn(0, 360)
_focusedItem.value = null
_forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.getForYou(minutes) }
.onSuccess { rows ->
_forYou.value = ForYouUiState(
rows = rows,
availableMinutes = minutes,
loading = false,
)
rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem)
}
.onFailure {
_forYou.update {
it.copy(
loading = false,
error = "For You is temporarily unavailable",
)
}
}
}
}
fun refreshAll() {
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
@@ -189,18 +224,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private suspend fun loadBatchHome() {
runCatching { repository.getHome() }
.onSuccess { home ->
val taggedHome = home.withAiringTodayTags()
_state.update { current ->
current.copy(
continueWatching = home.continueWatching,
nextUp = home.nextUp,
favorites = home.favorites,
latestMovies = home.latestMovies,
continueWatching = taggedHome.continueWatching,
nextUp = taggedHome.nextUp,
favorites = taggedHome.favorites,
latestMovies = taggedHome.latestMovies,
// Recommendation rows are built in the background by the gateway,
// so an early response can arrive without them. Keeping the rows
// we already had stops the strip flickering out and back in.
rows = home.rows.ifEmpty { current.rows },
rows = taggedHome.rows.ifEmpty { current.rows },
loading = emptySet(),
hasRefreshError = home.partial,
hasRefreshError = taggedHome.partial,
statusMessage = null,
// A successful response is the only thing that clears the
// maintenance screen, so a retry that fails keeps it up.
@@ -210,6 +246,10 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem)
}
// Home may have been cached just before the background recommendation
// build completed. Pull the dedicated endpoint after the fast home draw
// so personalized Shows shelves appear on this visit, not a minute later.
refreshRecommendationRows()
}
.onFailure { error ->
val maintenance = isMaintenanceError(error)
@@ -224,13 +264,30 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
private suspend fun refreshRecommendationRows() {
val fresh = runCatching { repository.getRecommendations() }.getOrNull() ?: return
_state.update { state ->
val airingTodayKeys = state.rows.airingTodayShowKeys()
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
val fixedRows = state.rows.filterNot { row ->
row.id == "recommended" ||
row.id.startsWith("similar:") ||
row.id.startsWith("curated:")
}
state.copy(rows = fixedRows + taggedFresh)
}
}
/**
* Updates local metadata immediately, then enriches it only after focus settles.
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
*/
fun focusItem(item: BaseItem) {
val cached = synchronized(metadataCache) { metadataCache[item.id] }
_focusedItem.value = cached ?: item
val focused = (cached ?: item).copy(
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
)
_focusedItem.value = focused
metadataJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
@@ -246,9 +303,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
synchronized(metadataCache) { metadataCache[item.id] = details }
val taggedDetails = details.copy(
membyAiringToday = focused.membyAiringToday,
membyRecommendationReason = focused.membyRecommendationReason,
membyCompatibility = focused.membyCompatibility,
)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = details
_focusedItem.value = taggedDetails
}
}
}
@@ -411,6 +473,40 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
return copy(
rows = rows.withAiringTodayRowTags(airingTodayKeys),
continueWatching = continueWatching.withAiringTodayItemTags(airingTodayKeys),
nextUp = nextUp.withAiringTodayItemTags(airingTodayKeys),
favorites = favorites.withAiringTodayItemTags(airingTodayKeys),
latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys),
)
}
private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
firstOrNull { it.id == "sonarr-airing-today" }
?.items
.orEmpty()
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
.filterTo(mutableSetOf(), String::isNotEmpty)
private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRow> =
map { row -> row.copy(items = row.items.withAiringTodayItemTags(keys)) }
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
map { item ->
if (!item.isSonarrSchedule && item.isSeries && item.name.showMatchKey() in keys) {
item.copy(membyAiringToday = true)
} else {
item
}
}
private fun String.showMatchKey(): String =
lowercase().filter(Char::isLetterOrDigit)
class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -58,7 +59,7 @@ import kotlinx.coroutines.delay
private val MaintenanceAccent = Color(0xFF52B54B)
private val MaintenanceTitle = Color(0xFFF2F5F7)
private val MaintenanceBody = Color(0xFFAEB7BF)
private val MaintenanceFaint = Color(0xFF7E888F)
private val MaintenanceFaint = Color(0xFFA2ADB5)
/** How long between automatic retries while the gateway is down. */
private const val RETRY_SECONDS = 30
@@ -117,7 +118,7 @@ fun MaintenanceScreen(
label = "maintenance-entrance",
)
var secondsLeft by remember { mutableStateOf(RETRY_SECONDS) }
var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) }
LaunchedEffect(message) {
// Restarts whenever the message changes, so a failed retry resets the clock.
secondsLeft = RETRY_SECONDS
@@ -194,6 +195,21 @@ fun MaintenanceScreen(
}
}
// The animations here are infinite, so a preview catches them mid-phase. That is fine for
// checking layout and colour; the motion itself only reads correctly on a device.
@TvPreview
@Composable
private fun MaintenanceScreenPreview() {
PreviewSurface {
MaintenanceScreen(
message = "Back after dinner — the library is being reorganised.",
contentFocusRequester = remember { FocusRequester() },
navigationFocusRequester = remember { FocusRequester() },
onRetry = {},
)
}
}
/** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */
@Composable
private fun MaintenanceBackdrop(glow: Float) {
@@ -60,7 +60,7 @@ import kotlinx.coroutines.launch
private val UpdateAccent = Color(0xFF52B54B)
private val UpdateTitle = Color(0xFFF2F5F7)
private val UpdateBody = Color(0xFFAEB7BF)
private val UpdateFaint = Color(0xFF7E888F)
private val UpdateFaint = Color(0xFFA2ADB5)
/**
* The update prompt, shown over the home screen.
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@ package com.ponzischeme89.memby.ui.screensaver
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
/** Opens an item in Emby's installed Android or Android TV client. */
internal object EmbyAppLauncher {
@@ -19,7 +19,7 @@ internal object EmbyAppLauncher {
)
for (packageName in packageCandidates) {
for (link in links) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
.setPackage(packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(context.packageManager) != null) {
@@ -42,6 +42,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -79,6 +80,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
@@ -625,7 +627,7 @@ private fun Slideshow(
/** A high-legibility, always-current clock beside the slide-progress indicator. */
@Composable
private fun CurrentTime(modifier: Modifier = Modifier) {
var now by remember { mutableStateOf(System.currentTimeMillis()) }
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
now = System.currentTimeMillis()
@@ -890,7 +892,7 @@ private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
var darkPixels = 0
for (y in 0 until bitmap.height step 2) {
for (x in 0 until bitmap.width step 2) {
val pixel = bitmap.getPixel(x, y)
val pixel = bitmap[x, y]
if (android.graphics.Color.alpha(pixel) < 48) continue
opaquePixels++
val luminance = (
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,8 @@ package com.ponzischeme89.memby.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.text.font.FontFamily
import android.graphics.Typeface
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.em
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
@@ -17,11 +18,23 @@ private val EmbyColors = darkColorScheme(
@Composable
fun MembyTheme(content: @Composable () -> Unit) {
// Android's native medium face is always available on TV, so it looks refined
// without a downloaded font or a first-render font swap.
val tvFont = FontFamily(Typeface.create("sans-serif-medium", Typeface.NORMAL))
// Use Android's complete Roboto-backed sans family rather than pinning every label
// to sans-serif-medium. The generic family lets Compose select real regular,
// medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy
// and making body copy substantially easier to scan from TV distance.
//
// A platform family also has every script Android supports, adds no APK weight, and
// cannot flash or fail while a downloadable font is fetched.
val appTextStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
// Slightly open tracking and leading keep small metadata and multi-line
// summaries from feeling cramped without making large headings look loose.
letterSpacing = 0.006.em,
lineHeight = 1.22.em,
)
MaterialTheme(colorScheme = EmbyColors) {
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = tvFont)) {
CompositionLocalProvider(LocalTextStyle provides appTextStyle) {
content()
}
}
@@ -2,10 +2,10 @@ package com.ponzischeme89.memby.update
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -165,7 +165,7 @@ class UpdateChecker(private val context: Context) {
runCatching {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}

Before

Width:  |  Height:  |  Size: 849 KiB

After

Width:  |  Height:  |  Size: 849 KiB

+4
View File
@@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 320x180 is the size the Leanback launcher requires of a banner, so the generic
"keep vectors under 200x200" advice does not apply here. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="320dp"
android:height="180dp"
tools:ignore="VectorRaster"
android:viewportWidth="320"
android:viewportHeight="180">
<path
+38 -1
View File
@@ -2,8 +2,45 @@
<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="playback_reconnecting">Connection interrupted</string>
<string name="playback_retrying_now">Trying the stream again…</string>
<string name="playback_refreshing_stream">Requesting a fresh stream from Emby…</string>
<string name="playback_server_unreachable">Media server unavailable</string>
<string name="playback_server_unreachable_detail">Memby couldnt request a fresh stream. Check that the server is online, then try again.</string>
<string name="playback_try_again">Try again</string>
<string name="playback_back_to_memby">Back to Memby</string>
<plurals name="playback_retrying_in">
<item quantity="one">Reconnecting in %1$d second…</item>
<item quantity="other">Reconnecting in %1$d seconds…</item>
</plurals>
<string name="player_now_playing">NOW PLAYING</string>
<string name="player_position_separator">/</string>
<string name="player_loading_duration">Loading duration…</string>
<string name="player_live">Live</string>
<string name="player_back">Back to previous screen</string>
<string name="player_hide_controls">Hide controls</string>
<string name="player_playback_options">PLAYBACK OPTIONS</string>
<string name="player_subtitles">Subtitles</string>
<string name="player_cast">Cast</string>
<string name="player_cast_loading">Loading cast…</string>
<string name="player_cast_empty">No cast information is available.</string>
<string name="player_subtitle_overlay_hint">Choose a track and tune the text size without leaving playback.</string>
<string name="player_subtitle_track">SUBTITLE TRACK</string>
<string name="player_text_size">TEXT SIZE</string>
<string name="player_back_to_close">BACK · CLOSE</string>
<string name="player_ends_at">Ends at %1$s</string>
<string name="player_preroll_countdown_initial">Starting in 5 seconds…</string>
<plurals name="player_preroll_countdown">
<item quantity="one">Starting in %1$d second…</item>
<item quantity="other">Starting in %1$d seconds…</item>
</plurals>
<string name="player_preroll_starting">Starting now…</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
<string name="next_up_starting_in">Starting in %1$ds</string>
<string name="next_up_starting_now">Starting now…</string>
<string name="app_name">Memby</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="dream_description">Memby movie &amp; TV backdrops</string>
<string name="developer_name">ponzischeme89</string>
</resources>
+2
View File
@@ -1,5 +1,7 @@
<resources>
<style name="Theme.Memby" parent="@android:style/Theme.Material.NoActionBar">
<!-- Match the Compose type system in PlayerActivity and every platform widget. -->
<item name="android:fontFamily">sans-serif</item>
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
@@ -1,9 +1,13 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -23,6 +27,17 @@ class GatewayPayloadTest {
explicitNulls = false
}
@Test
fun `decodes explicit client server protocol mismatch`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","serverProtocol":1}""",
)
assertEquals(false, status.compatible)
assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage)
assertEquals(1, status.serverProtocol)
}
@Test
fun `decodes a home payload with emby-shaped items`() {
val payload = """
@@ -79,6 +94,30 @@ class GatewayPayloadTest {
assertEquals("Solaris", home.rows.last().items.single().name)
}
@Test
fun `decodes explainable For You metadata`() {
val payload = """
{
"rows":[{
"id":"for-you:picks",
"title":"Top picks that fit in 60 minutes",
"kind":"for-you",
"items":[{
"Id":"9","Name":"Pilot","Type":"Episode",
"MembyRecommendationReason":"Matches your Drama viewing · fits your 60-minute window",
"MembyCompatibility":"Direct plays well on this TV"
}]
}]
}
""".trimIndent()
val item = json.decodeFromString<com.ponzischeme89.memby.data.model.GatewayRows>(payload)
.rows.single().items.single()
assertTrue(item.membyRecommendationReason!!.contains("60-minute"))
assertEquals("Direct plays well on this TV", item.membyCompatibility)
}
@Test
fun `decodes the informational Sonarr schedule row`() {
val payload = """
@@ -135,6 +174,15 @@ class GatewayPayloadTest {
assertTrue(home.partial)
}
@Test
fun `decodes persisted recent searches`() {
val history = json.decodeFromString<GatewaySearchHistory>(
"""{"queries":["severance","slow horses","arrival"]}""",
)
assertEquals(listOf("severance", "slow horses", "arrival"), history.queries)
}
@Test
fun `decodes a playback response`() {
val playback = json.decodeFromString<GatewayPlayback>(
@@ -145,6 +193,48 @@ class GatewayPayloadTest {
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
}
@Test
fun `decodes the next-episode response the player counts down to`() {
val next = json.decodeFromString<GatewayNextEpisode>(
"""
{
"item": {
"Id":"11","Name":"The Bicameral Mind","Type":"Episode",
"SeriesName":"Westworld","SeriesId":"7",
"IndexNumber":5,"ParentIndexNumber":2,
"ImageTags":{"Primary":"abc"}
},
"title": "Westworld The Bicameral Mind",
"url": "https://emby.example/Videos/11/stream?static=true",
"resumePositionMs": 0
}
""".trimIndent(),
)
assertEquals("11", next.item.id)
assertEquals("Westworld", next.item.seriesName)
// The banner's subtitle is built from these, so they have to survive the wire.
assertEquals("S2 · E5", next.item.episodeCode)
assertEquals(0L, next.resumePositionMs)
assertTrue(next.url.startsWith("https://emby.example/Videos/11/stream"))
}
@Test
fun `an episode without a season still gets a usable code`() {
val next = json.decodeFromString<GatewayNextEpisode>(
"""{"item":{"Id":"12","Name":"Special","Type":"Episode","IndexNumber":3},"url":"https://e/x"}""",
)
assertEquals("E3", next.item.episodeCode)
}
@Test
fun `an item with no episode numbering has no code`() {
val next = json.decodeFromString<GatewayNextEpisode>(
"""{"item":{"Id":"13","Name":"Arrival","Type":"Movie"},"url":"https://e/x"}""",
)
assertNull(next.item.episodeCode)
}
@Test
fun `device allowance response becomes a specific sign-in error`() {
val error = parseDeviceLimit(
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.DeviceProfile
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -13,4 +14,16 @@ class PlaybackReportMathTest {
fun negativePositionsAreClamped() {
assertEquals(0L, millisecondsToTicks(-1L))
}
@Test
fun deviceProfileExternalizesTextAndEncodesBitmapSubtitles() {
val profiles = DeviceProfile.embyAndroidTv().subtitleProfiles.associate {
it.format to it.method
}
assertEquals("External", profiles["srt"])
assertEquals("External", profiles["ass"])
assertEquals("External", profiles["mov_text"])
assertEquals("Encode", profiles["pgssub"])
assertEquals("Encode", profiles["dvdsub"])
}
}
@@ -36,4 +36,21 @@ class ProfileSettingsTest {
assertNull(settings.activeProfileId)
}
@Test
fun `for you choices belong to each profile`() {
val matt = profile.copy(forYouMinutes = 60, hasOpenedForYou = true)
val family = profile.copy(
id = "server::family",
userId = "family",
username = "FamilyTV",
forYouMinutes = 30,
hasOpenedForYou = false,
)
assertEquals(60, matt.forYouMinutes)
assertEquals(true, matt.hasOpenedForYou)
assertEquals(30, family.forYouMinutes)
assertEquals(false, family.hasOpenedForYou)
}
}
@@ -16,6 +16,8 @@ import org.junit.Test
*/
class ServerHomeRowsTest {
private val json = Json { ignoreUnknownKeys = true }
private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow(
id = id,
title = id.replaceFirstChar(Char::uppercase),
@@ -69,6 +71,14 @@ class ServerHomeRowsTest {
assertEquals("Favorites", personalizedFavoritesTitle(null))
}
@Test
fun `successful login welcome uses authenticated username`() {
assertEquals(
"You're now logged in as Matt. Welcome to Memby!",
loginWelcomeMessage(" Matt "),
)
}
@Test
fun `disabling a section hides its rows but never the recommendations`() {
val settings = Settings(homeSections = "continue")
@@ -93,6 +103,91 @@ class ServerHomeRowsTest {
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
}
@Test
fun `For You destination keeps reasons visible and exposes loading placeholder`() {
val loading = forYouBrowseRows(ForYouUiState(loading = true))
assertEquals(1, loading.size)
assertTrue(loading.single().loading)
val rows = forYouBrowseRows(
ForYouUiState(
rows = listOf(
row("for-you:picks", "for-you", "pick"),
row("for-you:because:six-feet-under", "for-you", "because"),
row("for-you:genre:drama", "for-you", "genre"),
),
availableMinutes = 60,
),
)
assertEquals(
listOf(
"for-you:picks",
"for-you:because:six-feet-under",
"for-you:genre:drama",
),
rows.map { it.id },
)
assertTrue(rows.all { it.kind == MediaRowKind.MOVIES })
assertTrue(rows.all { it.showSecondaryMetadata })
}
@Test
fun `shows destination includes empty favorites and ranked server shelves`() {
val rankedRows = listOf(
row("curated:comedy-shows", "shows", "c1"),
row("curated:horror-shows", "shows", "h1"),
row("curated:drama-shows", "shows", "d1"),
)
val state = HomeUiState(
rows = serverRows + rankedRows,
favorites = emptyList(),
loading = emptySet(),
)
val rows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
assertEquals(
listOf(
"next-up",
"continue-shows",
"favourite-shows",
"curated:comedy-shows",
"curated:horror-shows",
"curated:drama-shows",
),
rows.map { it.id },
)
assertTrue(rows.first { it.id == "favourite-shows" }.items.isEmpty())
}
@Test
fun `movies destination uses personalised genre and studio shelves`() {
val rankedRows = listOf(
row("curated:movies:studio:pixar", "movies", "p1"),
row("curated:movies:genre:animation", "movies", "a1"),
row("curated:drama-shows", "shows", "s1"),
row("recommended", "recommended", "mixed"),
)
val state = HomeUiState(
rows = serverRows + rankedRows,
favorites = listOf(BaseItem(id = "f1", type = "Movie")),
latestMovies = listOf(BaseItem(id = "latest", type = "Movie")),
loading = emptySet(),
)
val rows = homeRowsFor(BrowseDestination.MOVIES, state, Settings())
assertEquals(
listOf(
"curated:movies:studio:pixar",
"curated:movies:genre:animation",
"favourite-movies",
),
rows.map { it.id },
)
assertTrue(rows.none { it.id == "latest-movies" || it.id == "recommended" })
}
@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")
@@ -160,7 +255,7 @@ class ServerHomeRowsTest {
fun `a cache written before rows existed still decodes`() {
val legacy = """{"continueWatching":[{"Id":"a"}],"favorites":[],"nextUp":[],"latestMovies":[]}"""
val restored = HomeUiState.from(Json { ignoreUnknownKeys = true }.decodeFromString<HomeCache>(legacy))
val restored = HomeUiState.from(json.decodeFromString<HomeCache>(legacy))
assertTrue(restored.rows.isEmpty())
assertEquals("a", restored.continueWatching.single().id)
+14 -2
View File
@@ -5,9 +5,12 @@ services:
build: ./server
restart: unless-stopped
ports:
- "${MEMBY_PORT:-8080}:8080"
# The NAS reverse proxy for https://mserver.sublogue.com targets this fixed
# port. Keep host and container on 32768 so Docker's published mapping and
# the process listener cannot drift apart.
- "${MEMBY_PORT:-32768}:32768"
environment:
MEMBY_LISTEN_ADDR: ":8080"
MEMBY_LISTEN_ADDR: ":32768"
# 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}"
@@ -43,6 +46,15 @@ services:
MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}"
MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}"
MEMBY_SONARR_TTL: "${MEMBY_SONARR_TTL:-5m}"
# Optional Tracearr public API. Memby reads recent playback analytics to rank
# the dedicated For You area; the token never leaves this container.
MEMBY_TRACEARR_URL: "${MEMBY_TRACEARR_URL:-}"
MEMBY_TRACEARR_API_KEY: "${MEMBY_TRACEARR_API_KEY:-}"
MEMBY_TRACEARR_SERVER_ID: "${MEMBY_TRACEARR_SERVER_ID:-}"
MEMBY_TRACEARR_SYNC_INTERVAL: "${MEMBY_TRACEARR_SYNC_INTERVAL:-5m}"
MEMBY_TRACEARR_FULL_INTERVAL: "${MEMBY_TRACEARR_FULL_INTERVAL:-24h}"
MEMBY_FOR_YOU_MIN_REBUILD_AGE: "${MEMBY_FOR_YOU_MIN_REBUILD_AGE:-10m}"
MEMBY_FOR_YOU_REFRESH_INTERVAL: "${MEMBY_FOR_YOU_REFRESH_INTERVAL:-30m}"
volumes:
- memby-releases:/data/releases
depends_on:
+125 -16
View File
@@ -13,13 +13,13 @@ pointing at Emby itself; only metadata and artwork traverse the gateway.
cp .env.example .env # from the repo root
$EDITOR .env # set MEMBY_EMBY_URL and POSTGRES_PASSWORD
docker compose up -d --build
curl localhost:8080/readyz
curl localhost:32768/readyz
```
Then build the TV app against it:
```powershell
.\gradlew.bat assembleDebug -Pmemby.gatewayUrl=http://<host>:8080
.\gradlew.bat assembleDebug -Pmemby.gatewayUrl=https://mserver.sublogue.com
```
Leaving `memby.gatewayUrl` blank keeps the app on its original direct-to-Emby path, so a
@@ -35,6 +35,26 @@ go run ./cmd/memby-server # needs Postgres + Redis reachable
On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`.
## Deploy to the NAS
```powershell
.\deploy-server.ps1 # from the repo root; PowerShell 7
```
Streams the local `server/`, `docker-compose.yml` and `.env.example` to the NAS over one
SSH connection, builds there, and swaps the release in only once
Postgres, Redis and the server all report healthy — otherwise the previous release is put
back. The Postgres volume survives every deployment.
Configuration comes from the local `.env.example`, which holds real values: each deployment
installs it as the NAS's `.env`, keeping the previous one as `.env.previous`. It validates
the fixed `32768` reverse-proxy port and required admin/Emby secrets before activation.
Changing `POSTGRES_PASSWORD` intentionally recreates the PostgreSQL volume with an empty
database and the new password.
It deploys the **local working tree**, including uncommitted server changes.
`-SourceDirectory`, `-Destination`, `-RemoteHost` and `-HealthTimeoutSeconds` cover the rest.
## Logs
The server writes one compact, structured text line per event, designed for
@@ -58,14 +78,19 @@ headers attached.
| 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/status` | Lightweight live maintenance state plus informational alerts; 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/for-you?minutes=` | Tracearr-powered, explainable picks for a 0/30/60/120-minute viewing window |
| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update |
| GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request |
| GET | `/v1/search?q=&limit=` | Library search |
| GET | `/v1/search?q=&limit=` | Library search (title, series name, year, genre, studio) |
| GET | `/v1/search/history` | Current user's distinct searches from the last 30 days |
| POST | `/v1/search/history` | Record a successful search for the current user |
| GET | `/v1/items/{id}` | Full metadata for one item |
| GET | `/v1/items/{id}/episodes` | Complete episode browser for a series, grouped into seasons by the TV |
| GET | `/v1/items/{id}/playback` | Resolves series → episode, returns a direct-play URL |
| GET | `/v1/items/{id}/next` | Episode following this one, or 404 when nothing does |
| GET | `/v1/items/{id}/trailer` | First local trailer, or 404 |
| POST | `/v1/items/{id}/favorite` | `{"value":true}` |
| POST | `/v1/items/{id}/played` | `{"value":true}` |
@@ -77,7 +102,7 @@ headers attached.
### Server-driven rows
`/v1/home` returns a `rows` array — order, titles and kinds all decided here — plus the
four fixed rows repeated flat for the client's offline cache:
three fixed rows repeated flat for the client's offline cache:
```json
{
@@ -86,21 +111,21 @@ four fixed rows repeated flat for the client's offline cache:
{"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": "latest-movies", "title": "Recent New Releases", "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": "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": [...],
"continueWatching": [...], "favorites": [...], "latestMovies": [...],
"partial": false
}
```
The TV renders whatever arrives, so a new row ships without an app release. `kind` picks
the card shape; an unrecognised kind falls back to poster cards rather than being dropped.
The user's own section toggles still hide the four fixed rows, but never rows the server
The user's own section toggles still hide the three fixed rows, but never rows the server
invented — nobody opted out of a row that did not exist when they last opened Settings.
### Recommendations
@@ -138,6 +163,49 @@ Curated shelves require at least one completed library import because their genr
filter runs against Postgres. Emby's exact studio naming is preserved during import;
Apple matching accepts `Apple TV+`, `Apple TV Plus` and `Apple Studios`.
### Tracearr-powered For You
`/v1/for-you` is separate from `/v1/home`, so the launcher remains a single fast request
and never waits for analytics. The Android rail loads For You only when opened and lets
the viewer choose 30 minutes, one hour, two hours or any length.
The gateway combines:
- Emby history, favourites and catalogue metadata;
- Tracearr's read-only public `/api/v1/public/history` data, including completion,
aggregate watch time, device/platform, codecs and direct-play/transcode outcomes;
- Memby's own recent card focus, dwell and selection events;
- the requested time window and the item's Emby runtime.
Tracearr currently exposes no per-user or `since` history query. A background importer
therefore pages newest-first and upserts recommendation-relevant fields by Tracearr's
stable `(serverId, sessionId)` key. Incremental passes run every five minutes and stop
after two unchanged pages (at most ten); a daily full pass catches late or out-of-order
updates and reconciles deletions. Imports are idempotent.
Postgres retains the compact source sessions, one derived profile per enabled Emby user,
and every eligible ranked Movie/Series candidate. The background builder reads Tracearr's
public users endpoint and exact-matches usernames against Emby's user list. Unmatched
Emby users still receive Emby-history recommendations; similar-looking names are never
fuzzily joined without an explicit operator decision.
Keeping the pool deliberately much larger than the cards shown gives the request-time
`minutes` filter enough headroom. `/v1/for-you` reads up to 240 ranked candidates and
derives several de-duplicated shelves: top picks, up to two distinct “Because you
finished …” shelves, up to two genre shelves, and a television-compatible shelf when
there is enough playback evidence. Completed-title evidence is distributed
deterministically across relevant candidates rather than allowing the newest Drama
title to explain every Drama recommendation.
The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Pools may be
30 minutes stale; rebuilds inside a ten-minute window are coalesced. A cold or failed
rebuild uses the original live Tracearr/Emby path, so persistence cannot blank the area.
Every returned item is enriched with `MembyRecommendationReason` and
`MembyCompatibility`. The TV shows the reason on the card and in the focused metadata
panel. Missing Tracearr or sparse codec evidence degrades to Emby/Memby signals rather
than blanking the area; Tracearr errors never affect the essential home rows.
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
@@ -156,20 +224,52 @@ Sonarr poster and fanart requests are proxied through the gateway; its API key i
sent to the TV. The shared Redis entry is refreshed every five minutes by default, not
once per user.
### Aired banners
The same calendar data drives a slide-in banner on open clients: when an episode's air
time passes and Sonarr has not imported it yet, `/v1/status` starts returning an alert —
```json
{"maintenance": false, "message": "", "alerts": [
{"id": "sonarr:7:42:aired", "kind": "sonarr-aired", "title": "Northbound",
"message": "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
"itemId": "sonarr:7:42", "imageTag": "sonarr", "airedAt": "2026-07-27T21:00:00+12:00"}]}
```
`/v1/status` is the poll the app already runs every ten seconds, so this needs no push
channel and no second connection. Alerts read from the cached calendar, so a polling
client never costs a Sonarr request of its own; they are newest-first and capped at three.
Episodes already downloaded and unmonitored ones say nothing — the first is on the home
screen already, the second is never coming.
The server has no idea which TVs have seen what, so the client owns that: it persists the
ids it has shown and displays each alert once, for ten seconds, without taking focus — a
ring on the banner counts that down, since the viewer cannot dismiss it by remote.
Clients poll only while a Memby screen is in the foreground, and record an alert as shown
only when the banner is actually on screen. So keep offering an alert for the whole window
rather than once: a TV that was showing its screensaver when the episode aired will pick
the alert up when someone comes back to it, and one that never does simply lets the alert
expire with the window.
`MEMBY_SONARR_ALERT_WINDOW` bounds how long after air time an alert stays current;
`0` disables banners while leaving the airing-today row alone.
## Admin interface
`http://<host>:8080/admin/` — a single self-contained page for library imports, the
`https://mserver.sublogue.com/admin/` — a single self-contained page for library imports, the
maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every
`/admin` route 404s so it cannot be left exposed by accident. Paste the token into the
field at the top of the page; it is kept in the browser's local storage and sent as a
bearer header. Put the whole path behind your reverse proxy's own auth as well if the
gateway is reachable from outside the LAN.
`/admin` route 404s so it cannot be left exposed by accident. Loading the page establishes
a persistent HttpOnly admin cookie automatically, so the configured token survives server
and browser reboots without being pasted into the page. Anyone who can load this local-only
page is therefore an admin: put the whole path behind your reverse proxy's own auth before
making the gateway reachable outside the LAN.
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/admin/` | The page |
| GET | `/admin/api/status` | Library counts, sync history, maintenance state |
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
| POST | `/admin/api/for-you` | `{"action":"incremental-import"}`, `{"action":"full-import"}`, or `{"action":"rebuild-all"}` |
| POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` |
| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` |
| GET | `/admin/api/analytics?days=7` | Row engagement |
@@ -301,8 +401,10 @@ re-reads Emby rather than serving a row it just contradicted. Partial home paylo
served but never cached. The `r:` namespace is deliberately excluded from that wipe (see
Recommendations above).
Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache,
losing Postgres signs everyone out. A session is unique per Emby user and stable device
Postgres is the durable half: it holds gateway sessions, the imported Emby catalogue,
Tracearr's compact recommendation history, derived profiles and prepared candidate pools.
Losing Redis costs a cold cache; losing Postgres signs everyone out and requires catalogue
and recommendation backfills. A gateway 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.
@@ -314,13 +416,19 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o
| `MEMBY_EMBY_PUBLIC_URL` | = `MEMBY_EMBY_URL` | What TVs stream from |
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `MEMBY_LISTEN_ADDR` | `:8080` | |
| `MEMBY_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | |
| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests |
| `MEMBY_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_TRACEARR_URL` / `MEMBY_TRACEARR_API_KEY` | *empty* | Read-only Tracearr public API; both values are required |
| `MEMBY_TRACEARR_SERVER_ID` | *empty* | Optional server scope when Tracearr monitors several servers |
| `MEMBY_TRACEARR_SYNC_INTERVAL` | `5m` | Incremental session import cadence; `0` disables scheduling |
| `MEMBY_TRACEARR_FULL_INTERVAL` | `24h` | Full reconciliation cadence |
| `MEMBY_FOR_YOU_MIN_REBUILD_AGE` | `10m` | Coalesces bursts of pool invalidations |
| `MEMBY_FOR_YOU_REFRESH_INTERVAL` | `30m` | Acceptable prepared-pool staleness |
| `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 |
@@ -331,6 +439,7 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o
| `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_SONARR_ALERT_WINDOW` | `3h` | How long after air time an "aired" banner stays current; `0` disables banners |
| `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 |
+58 -4
View File
@@ -20,11 +20,13 @@ import (
"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/foryou"
"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"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
func main() {
@@ -41,15 +43,15 @@ func main() {
}
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
log := logging.New(os.Stdout, logLevel)
log, events := logging.NewBuffered(os.Stdout, logLevel, 20_000)
if err := run(log); err != nil {
if err := run(log, events); err != nil {
log.Error("fatal", "error", err)
os.Exit(1)
}
}
func run(log *slog.Logger) error {
func run(log *slog.Logger, events *logging.Buffer) error {
cfg, err := config.Load()
if err != nil {
return err
@@ -119,21 +121,54 @@ func run(log *slog.Logger) error {
// Candidates come from the imported library when one exists, which keeps the
// recommendation rebuild off Emby entirely.
recommender.Library = st
recommender.Behavior = st
var tracearrClient *tracearr.Client
if cfg.TracearrURL != "" {
tracearrClient = tracearr.New(
cfg.TracearrURL,
cfg.TracearrAPIKey,
cfg.TracearrServerID,
cfg.UpstreamTimeout,
)
recommender.Tracearr = tracearrClient
log.Info("tracearr recommendation signals enabled", "url", cfg.TracearrURL)
}
syncer := library.NewSyncer(embyClient, st, emby.Credentials{
UserID: cfg.SyncUserID,
Token: cfg.SyncAPIKey,
DeviceID: "memby-gateway-sync",
}, log)
var forYouService *foryou.Service
if tracearrClient != nil {
forYouService = foryou.New(
st, tracearrClient, recommender, log,
cfg.ForYouMinRebuildAge, cfg.ForYouRefreshInterval,
)
forYouService.ConfigureHouseholdUsers(embyClient, emby.Credentials{
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey,
DeviceID: "memby-for-you-builder", DeviceName: "Memby For You builder",
})
syncer.SetAfterSync(func() {
go func() {
rebuildCtx, cancel := context.WithTimeout(context.Background(), cfg.RecommendTimeout)
defer cancel()
forYouService.MarkAllDirty(rebuildCtx)
_ = forYouService.RebuildAll(rebuildCtx, false)
}()
})
}
server := api.New(cfg, api.Deps{
Emby: embyClient,
Store: st,
Cache: ca,
Recommender: recommender,
ForYou: forYouService,
Sonarr: sonarrClient,
Syncer: syncer,
Log: log,
Events: events,
})
if err := server.LoadMaintenance(ctx); err != nil {
@@ -154,6 +189,25 @@ func run(log *slog.Logger) error {
}
}()
}
if forYouService != nil {
go forYouService.Schedule(
ctx,
cfg.TracearrSyncInterval,
cfg.TracearrFullInterval,
cfg.ForYouRefreshInterval,
)
go func() {
importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
defer cancel()
if _, err := forYouService.Import(importCtx, false); err != nil {
log.Warn("startup Tracearr import failed", "error", err)
return
}
if err := forYouService.RebuildAll(importCtx, false); err != nil {
log.Warn("startup For You rebuild failed", "error", err)
}
}()
}
go pruneAnalytics(ctx, st, cfg.AnalyticsRetention, log)
httpServer := &http.Server{
@@ -195,7 +249,7 @@ func probeHealth() error {
if addr == "" {
addr = ":8080"
}
// ":8080" and "0.0.0.0:8080" both mean "connect to localhost" from in here.
// Wildcard listen addresses still mean "connect to localhost" from in here.
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
addr = "127.0.0.1" + addr[idx:]
}
+107 -15
View File
@@ -6,6 +6,7 @@ import (
_ "embed"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
@@ -17,6 +18,8 @@ import (
//go:embed admin.html
var adminPage []byte
const adminCookieName = "memby_admin"
// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
@@ -26,7 +29,9 @@ func (s *Server) adminRoutes() http.Handler {
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
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))
@@ -34,7 +39,22 @@ func (s *Server) adminRoutes() http.Handler {
return mux
}
// adminAuth guards the admin API with a shared token, compared in constant time.
func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
if s.events == nil {
writeJSON(w, http.StatusOK, map[string]any{
"events": []any{}, "next": 0, "oldest": 0, "latest": 0,
"dropped": 0, "hasMore": false,
})
return
}
after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
limit := queryInt(r, "limit", 500, 1000)
writeJSON(w, http.StatusOK, s.events.Events(after, limit))
}
// adminAuth guards the admin API with the shared token. Browser requests use the
// persistent HttpOnly cookie established by the admin page; automation can continue to
// send the token as a Bearer header.
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
@@ -42,6 +62,11 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return
}
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if presented == "" {
if cookie, err := r.Cookie(adminCookieName); err == nil {
presented = cookie.Value
}
}
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
@@ -55,20 +80,30 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: s.cfg.AdminToken,
Path: "/admin",
MaxAge: 10 * 365 * 24 * 60 * 60,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// The page holds no secrets; the token is entered by the operator and kept in the
// browser's local storage.
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(adminPage)
}
type adminStatus struct {
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
ForYou store.ForYouStats `json:"forYou"`
ForYouRunning bool `json:"forYouRunning"`
}
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
@@ -87,13 +122,24 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
return
}
var forYouStats store.ForYouStats
forYouRunning := false
if s.forYou != nil {
forYouStats, err = s.forYou.Stats(ctx)
if err != nil {
s.log.Warn("For You stats failed", "error", err)
}
forYouRunning = s.forYou.Running()
}
writeJSON(w, http.StatusOK, adminStatus{
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
ForYou: forYouStats,
ForYouRunning: forYouRunning,
})
}
@@ -188,6 +234,52 @@ func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
}
type forYouAdminRequest struct {
Action string `json:"action"`
}
// handleAdminForYou provides the recovery controls needed for an idempotent backfill:
// import all Tracearr sessions again, or rebuild every active user's derived pool.
func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
if s.forYou == nil {
writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
return
}
var req forYouAdminRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
switch req.Action {
case "incremental-import", "full-import", "rebuild-all":
default:
writeError(w, http.StatusBadRequest,
`action must be "incremental-import", "full-import", or "rebuild-all"`)
return
}
if s.forYou.Running() {
writeError(w, http.StatusConflict, "For You maintenance is already running")
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), s.cfg.SyncTimeout)
defer cancel()
if req.Action != "rebuild-all" {
_, err := s.forYou.Import(ctx, req.Action == "full-import")
if err != nil {
s.log.Error("manual Tracearr import failed", "action", req.Action, "error", err)
return
}
}
if err := s.forYou.RebuildAll(ctx, true); err != nil {
s.log.Error("manual For You rebuild failed", "action", req.Action, "error", err)
}
}()
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "started", "action": req.Action,
})
}
type maintenanceRequest struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
+182 -13
View File
@@ -15,7 +15,7 @@
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 980px; margin: 0 auto; display: grid; gap: 20px; }
main { width: 100%; margin: 0; display: grid; gap: 20px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
@@ -47,6 +47,32 @@
.muted { color: var(--muted); }
.banner { padding: 11px 14px; border-radius: 8px; background: #3a1d1c; color: #ffb3ad; display: none; }
.banner.show { display: block; }
.event-toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
.event-toolbar input[type=search] {
background:#0e1216; border:1px solid var(--line); border-radius:7px;
color:var(--text); padding:8px 10px; min-width:300px;
}
select {
background:#0e1216; color:var(--text); border:1px solid var(--line);
border-radius:7px; padding:8px;
}
.event-log {
height: min(58vh, 720px); min-height: 360px; overflow: auto; background:#090c0f;
border:1px solid var(--line); border-radius:7px; font:12px/1.45 ui-monospace, Consolas, monospace;
}
.event-line { display:grid; grid-template-columns:190px 62px 240px minmax(340px,1fr); gap:10px; padding:5px 9px; border-bottom:1px solid #171c21; }
.event-line:hover { background:#12171c; }
.event-time,.event-attrs { color:var(--muted); }
.event-level { font-weight:700; }
.event-level.ERROR { color:#ff8a80; }
.event-level.WARN { color:#f0c674; }
.event-level.DEBUG { color:#88a4bd; }
.event-empty { padding:20px; color:var(--muted); }
@media (max-width: 900px) {
body { padding:14px; }
.event-line { grid-template-columns:150px 55px 1fr; }
.event-attrs { grid-column:1 / -1; }
}
</style>
</head>
<body>
@@ -54,9 +80,6 @@
<header>
<h1>Memby admin</h1>
<span id="live" class="pill muted">connecting…</span>
<span style="flex:1"></span>
<input type="password" id="token" placeholder="Admin token" autocomplete="off">
<button id="save-token" class="secondary">Save</button>
</header>
<div id="error" class="banner"></div>
@@ -71,6 +94,17 @@
</div>
</section>
<section>
<h2>For You</h2>
<div class="stats" id="for-you-stats"><span class="muted">Loading…</span></div>
<div class="row">
<button id="for-you-import">Import recent sessions</button>
<button id="for-you-full" class="secondary">Full Tracearr backfill</button>
<button id="for-you-rebuild" class="secondary">Rebuild all pools</button>
<span class="muted" id="for-you-hint"></span>
</div>
</section>
<section>
<h2>Maintenance</h2>
<p class="muted" style="margin-top:0">
@@ -147,17 +181,29 @@
</table>
</div>
</section>
<section>
<h2>Live server events</h2>
<div class="event-toolbar">
<select id="event-level" aria-label="Minimum event level">
<option value="DEBUG">Debug and above</option>
<option value="INFO" selected>Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="event-search" placeholder="Filter message, path, version, status…">
<button id="event-pause" class="secondary">Pause</button>
<button id="event-clear" class="secondary">Clear view</button>
<button id="event-export" class="secondary">Export JSON</button>
<span id="event-stats" class="muted">Connecting…</span>
</div>
<div id="event-log" class="event-log" role="log" aria-live="polite">
<div class="event-empty">Waiting for server events…</div>
</div>
</section>
</main>
<script>
const tokenInput = document.getElementById('token');
tokenInput.value = localStorage.getItem('memby_admin_token') || '';
document.getElementById('save-token').addEventListener('click', () => {
localStorage.setItem('memby_admin_token', tokenInput.value.trim());
refresh();
});
function showError(message) {
const banner = document.getElementById('error');
banner.textContent = message || '';
@@ -168,7 +214,6 @@ async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
'Authorization': 'Bearer ' + (localStorage.getItem('memby_admin_token') || ''),
'Content-Type': 'application/json',
...(options.headers || {}),
},
@@ -193,6 +238,9 @@ function duration(ms) {
}
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
}[char]));
function renderStatus(status) {
const byType = status.library.byType || {};
@@ -211,6 +259,20 @@ function renderStatus(status) {
? 'Import running…'
: 'Automatic incremental import every ' + status.syncEvery + '.';
const forYou = status.forYou || {};
document.getElementById('for-you-stats').innerHTML =
'<div class="stat"><b>' + number(forYou.tracearrSessions) + '</b><span>Tracearr sessions</span></div>' +
'<div class="stat"><b>' + number(forYou.profiles) + '</b><span>user profiles</span></div>' +
'<div class="stat"><b>' + number(forYou.candidates) + '</b><span>ranked candidates</span></div>' +
'<div class="stat"><b style="font-size:15px">' + when(forYou.lastFullImport) +
'</b><span>last full import</span></div>';
const forYouRunning = Boolean(status.forYouRunning);
document.getElementById('for-you-import').disabled = forYouRunning;
document.getElementById('for-you-full').disabled = forYouRunning;
document.getElementById('for-you-rebuild').disabled = forYouRunning;
document.getElementById('for-you-hint').textContent =
forYouRunning ? 'For You maintenance running…' : 'Prepared pools normally refresh in the background.';
const maintenance = status.maintenance || {};
const state = document.getElementById('maintenance-state');
state.textContent = maintenance.enabled ? 'OFFLINE' : 'online';
@@ -311,6 +373,24 @@ document.getElementById('sync-full').addEventListener('click', () => {
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'full' }) }));
});
function forYouAction(action) {
return api('/admin/api/for-you', {
method: 'POST',
body: JSON.stringify({ action }),
});
}
document.getElementById('for-you-import').addEventListener('click', () =>
act(() => forYouAction('incremental-import')));
document.getElementById('for-you-full').addEventListener('click', () => {
if (!confirm('Backfill all Tracearr history and rebuild every active user pool?')) return;
act(() => forYouAction('full-import'));
});
document.getElementById('for-you-rebuild').addEventListener('click', () =>
act(() => forYouAction('rebuild-all')));
document.getElementById('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every TV?')) return;
act(() => api('/admin/api/maintenance', {
@@ -346,8 +426,97 @@ document.getElementById('update-disable').addEventListener('click', () =>
document.getElementById('days').addEventListener('change', refresh);
const eventState = {
cursor: 0,
records: [],
dropped: 0,
paused: false,
fetching: false,
};
const eventRanks = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
function eventText(event) {
return [event.message, ...Object.entries(event.attributes || {}).flat()].join(' ').toLowerCase();
}
function renderEvents() {
const minimum = eventRanks[document.getElementById('event-level').value] || 20;
const search = document.getElementById('event-search').value.trim().toLowerCase();
const filtered = eventState.records.filter((event) =>
(eventRanks[event.level] || 0) >= minimum && (!search || eventText(event).includes(search)));
// Rendering every retained record at once can freeze a browser during an incident.
// Keep all records available for filtering/export, but virtualise the visible tail.
const visible = filtered.slice(-2500);
const log = document.getElementById('event-log');
const pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 50;
log.innerHTML = visible.length ? visible.map((event) => {
const attrs = Object.entries(event.attributes || {})
.map(([key, value]) => escapeHtml(key) + '=' + escapeHtml(value)).join(' ');
return '<div class="event-line">' +
'<span class="event-time">' + escapeHtml(when(event.occurredAt)) + '</span>' +
'<span class="event-level ' + escapeHtml(event.level) + '">' + escapeHtml(event.level) + '</span>' +
'<span>' + escapeHtml(event.message) + '</span>' +
'<span class="event-attrs">' + attrs + '</span></div>';
}).join('') : '<div class="event-empty">No events match this filter.</div>';
if (pinned) log.scrollTop = log.scrollHeight;
document.getElementById('event-stats').textContent =
number(eventState.records.length) + ' retained · ' + number(filtered.length) + ' matching' +
(visible.length < filtered.length ? ' · showing latest ' + number(visible.length) : '') +
(eventState.dropped ? ' · ' + number(eventState.dropped) + ' overwritten before delivery' : '');
}
async function pollEvents() {
if (eventState.paused || eventState.fetching) return;
eventState.fetching = true;
try {
let pages = 0;
let page;
do {
page = await api('/admin/api/events?after=' + eventState.cursor + '&limit=1000');
eventState.cursor = page.next || eventState.cursor;
eventState.dropped += page.dropped || 0;
if ((page.events || []).length) {
eventState.records.push(...page.events);
if (eventState.records.length > 20000) {
eventState.records.splice(0, eventState.records.length - 20000);
}
}
pages += 1;
} while (page.hasMore && pages < 20 && !eventState.paused);
renderEvents();
showError('');
} catch (err) {
showError(err.message);
} finally {
eventState.fetching = false;
}
}
document.getElementById('event-pause').addEventListener('click', () => {
eventState.paused = !eventState.paused;
document.getElementById('event-pause').textContent = eventState.paused ? 'Resume' : 'Pause';
if (!eventState.paused) pollEvents();
});
document.getElementById('event-clear').addEventListener('click', () => {
eventState.records = [];
eventState.dropped = 0;
renderEvents();
});
document.getElementById('event-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(eventState.records, null, 2)], {type:'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'memby-events-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
});
document.getElementById('event-level').addEventListener('change', renderEvents);
document.getElementById('event-search').addEventListener('input', renderEvents);
refresh();
setInterval(refresh, 5000);
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
</script>
</body>
</html>
+75
View File
@@ -119,6 +119,41 @@ func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
}
}
func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Version", "0.9.1")
req.Header.Set("X-Memby-Protocol", "99")
rec := httptest.NewRecorder()
server.handleServiceStatus(rec, req, store.Session{})
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["compatible"] != false || body["compatibilityMessage"] == "" {
t.Fatalf("mismatch was not explicit: %v", body)
}
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(membyProtocolVersion) {
t.Fatalf("version diagnostics missing: %v", body)
}
}
func TestServiceStatusAcceptsCurrentProtocol(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Protocol", "1")
rec := httptest.NewRecorder()
server.handleServiceStatus(rec, req, store.Session{})
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["compatible"] != true || body["compatibilityMessage"] != "" {
t.Fatalf("current protocol should be compatible: %v", body)
}
}
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{})
@@ -163,6 +198,46 @@ func TestAdminAuthRejectsAWrongToken(t *testing.T) {
}
}
func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
req := httptest.NewRequest(http.MethodGet, "https://memby.local/admin/", nil)
rec := httptest.NewRecorder()
server.handleAdminPage(rec, req)
result := rec.Result()
cookies := result.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one admin cookie, got %d", len(cookies))
}
cookie := cookies[0]
if cookie.Name != adminCookieName || cookie.Value != "secret" {
t.Fatalf("unexpected admin cookie: %#v", cookie)
}
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("admin cookie is not hardened: %#v", cookie)
}
if cookie.MaxAge <= 0 || cookie.Path != "/admin" {
t.Fatalf("admin cookie is not persistent or scoped: %#v", cookie)
}
}
func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("the admin cookie should be accepted, got %d", rec.Code)
}
}
func TestToRowEventValidatesAndClamps(t *testing.T) {
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
+105 -23
View File
@@ -24,6 +24,8 @@ import (
"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/foryou"
serverlogging "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"
@@ -35,9 +37,11 @@ type Server struct {
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
forYou *foryou.Service
sonarr *sonarr.Client
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
recommendationBuilds recommendationBuilds
@@ -52,9 +56,11 @@ type Deps struct {
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
ForYou *foryou.Service
Sonarr *sonarr.Client
Syncer syncerHandle
Log *slog.Logger
Events *serverlogging.Buffer
}
func New(cfg config.Config, deps Deps) *Server {
@@ -67,9 +73,11 @@ func New(cfg config.Config, deps Deps) *Server {
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
forYou: deps.ForYou,
sonarr: deps.Sonarr,
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
}
}
@@ -86,13 +94,19 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/home", s.authed(s.handleHome))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("GET /v1/for-you", s.authed(s.handleForYou))
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll))
v1.Handle("GET /v1/update", s.authed(s.handleUpdate))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
@@ -138,15 +152,59 @@ func (s *Server) authed(h authedFunc) http.Handler {
writeError(w, http.StatusInternalServerError, "session lookup failed")
return
}
sess = s.captureClientIdentity(r, sess)
h(w, r, sess)
})
}
// captureClientIdentity makes the session the durable source of attribution. Normal API
// calls refresh it from headers; authenticated artwork requests, which can only carry a
// query token, inherit the last identity reported by that same TV.
func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) store.Session {
changed := mergeClientIdentity(r, &sess)
if changed {
if err := s.store.UpdateSessionClientIdentity(
r.Context(), sess.TokenHash, sess.ClientVersion, sess.ClientProtocol,
); err != nil {
s.log.Warn("client identity update failed", "error", err)
} else {
s.cacheSession(r.Context(), sess)
}
}
return sess
}
func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
version := clientVersion(r)
protocol := clientProtocol(r)
changed := false
if version != "" && version != sess.ClientVersion {
sess.ClientVersion = version
changed = true
}
if protocol != "" && protocol != sess.ClientProtocol {
sess.ClientProtocol = protocol
changed = true
}
if version == "" && sess.ClientVersion != "" {
r.Header.Set("X-Memby-Version", sess.ClientVersion)
}
if protocol == "" && sess.ClientProtocol != "" {
r.Header.Set("X-Memby-Protocol", sess.ClientProtocol)
}
return changed
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Polling the live-log endpoint must not create another live-log record and
// become a self-sustaining stream.
if r.URL.Path == "/admin/api/events" {
return
}
// Path only: query strings can carry image tokens.
level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
@@ -154,10 +212,19 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start).Round(time.Millisecond),
"client_version", clientLogValue(clientVersion(r)),
"client_protocol", clientLogValue(clientProtocol(r)),
)
})
}
func clientLogValue(value string) string {
if value == "" {
return "unknown"
}
return value
}
// 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.
@@ -211,12 +278,14 @@ func newToken() (string, error) {
}
type cachedSession struct {
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
ClientVersion string `json:"v,omitempty"`
ClientProtocol string `json:"p,omitempty"`
}
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
@@ -228,13 +297,15 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
var cs cachedSession
if json.Unmarshal(raw, &cs) == nil {
return store.Session{
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
ClientVersion: cs.ClientVersion,
ClientProtocol: cs.ClientProtocol,
}, nil
}
}
@@ -248,16 +319,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
return store.Session{}, store.ErrNotFound
}
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
}); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
}
s.cacheSession(ctx, sess)
// Best-effort activity stamp; a failure here must not fail the request.
if err := s.store.Touch(ctx, hash); err != nil {
s.log.Warn("touch session failed", "error", err)
@@ -265,6 +327,26 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
return sess, nil
}
func (s *Server) cacheSession(ctx context.Context, sess store.Session) {
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion,
ClientProtocol: sess.ClientProtocol,
}); err == nil {
_ = s.cache.Set(
ctx,
cache.SessionKey(hex.EncodeToString(sess.TokenHash)),
raw,
s.cfg.SessionTTL,
)
}
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
+119 -1
View File
@@ -5,9 +5,11 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestBearerTokenSources(t *testing.T) {
@@ -116,6 +118,55 @@ func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) {
}
}
func TestEpisodeAfterUsesPositionNotListLength(t *testing.T) {
episode := func(id string) json.RawMessage {
return json.RawMessage(`{"Id":"` + id + `","Name":"Episode ` + id + `","SeriesName":"Westworld"}`)
}
t.Run("middle of a season", func(t *testing.T) {
items := []json.RawMessage{episode("1"), episode("2"), episode("3")}
raw, next, ok := episodeAfter(items, "2")
if !ok {
t.Fatal("expected an episode after 2")
}
if next.ID != "3" {
t.Fatalf("next = %q, want 3", next.ID)
}
if got := seriesNameOf(raw); got != "Westworld" {
t.Fatalf("series name = %q, want Westworld", got)
}
})
// Emby drops the leading entry for the first episode, so a two-item list can mean
// either "first, second" or "second-to-last, last" depending on where we are.
t.Run("first episode has no previous", func(t *testing.T) {
items := []json.RawMessage{episode("1"), episode("2")}
if _, next, ok := episodeAfter(items, "1"); !ok || next.ID != "2" {
t.Fatalf("next = %+v, ok = %v, want episode 2", next, ok)
}
})
t.Run("series finale has nothing after it", func(t *testing.T) {
items := []json.RawMessage{episode("1"), episode("2")}
if _, _, ok := episodeAfter(items, "2"); ok {
t.Fatal("the last episode must not resolve a next one")
}
})
t.Run("current episode missing from the result", func(t *testing.T) {
items := []json.RawMessage{episode("7"), episode("8")}
if _, _, ok := episodeAfter(items, "42"); ok {
t.Fatal("an unrelated result must not resolve a next episode")
}
})
t.Run("empty result", func(t *testing.T) {
if _, _, ok := episodeAfter(nil, "1"); ok {
t.Fatal("no episodes must not resolve a next one")
}
})
}
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the
// client's non-null List fields.
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
@@ -137,7 +188,22 @@ func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
}
}
// The four fixed rows must keep their order, ids and kinds: the client maps kinds onto
func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) {
resp := searchHistoryResponse{Queries: []string{}}
body, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := decoded["queries"].([]any); !ok {
t.Fatalf("queries encoded as %T, want array", decoded["queries"])
}
}
// The fixed rows must keep their order, ids and kinds: the client maps kinds onto
// card shapes and uses ids as Compose keys.
func TestBaseRowsShape(t *testing.T) {
rows := baseRows(homeResponse{
@@ -168,6 +234,29 @@ func TestBaseRowsShape(t *testing.T) {
}
}
func TestHomeForYouWindowAt(t *testing.T) {
tests := []struct {
hour int
id string
minutes int
}{
{hour: 4, id: "late-night", minutes: 60},
{hour: 5, id: "morning", minutes: 30},
{hour: 11, id: "morning", minutes: 30},
{hour: 12, id: "afternoon", minutes: 60},
{hour: 16, id: "afternoon", minutes: 60},
{hour: 17, id: "evening", minutes: 120},
{hour: 22, id: "evening", minutes: 120},
{hour: 23, id: "late-night", minutes: 60},
}
for _, test := range tests {
got := homeForYouWindowAt(time.Date(2026, time.July, 29, test.hour, 0, 0, 0, time.UTC))
if got.ID != test.id || got.Minutes != test.minutes {
t.Errorf("hour %d: got %#v, want id=%q minutes=%d", test.hour, got, test.id, test.minutes)
}
}
}
func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) {
var builds recommendationBuilds
@@ -200,3 +289,32 @@ func TestSummariseReadsResumePosition(t *testing.T) {
t.Fatalf("resume position = %d ms, want 3600000", got)
}
}
func TestMergeClientIdentityPersistsReportedHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Version", "0.1.60")
req.Header.Set("X-Memby-Protocol", "1")
sess := store.Session{}
if changed := mergeClientIdentity(req, &sess); !changed {
t.Fatal("reported identity should update the session")
}
if sess.ClientVersion != "0.1.60" || sess.ClientProtocol != "1" {
t.Fatalf("session identity = %q/%q", sess.ClientVersion, sess.ClientProtocol)
}
}
func TestMergeClientIdentityAttributesHeaderlessAuthenticatedRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/images/42/primary?t=token", nil)
sess := store.Session{ClientVersion: "0.1.60", ClientProtocol: "1"}
if changed := mergeClientIdentity(req, &sess); changed {
t.Fatal("inheriting identity should not write the session again")
}
if got := clientVersion(req); got != "0.1.60" {
t.Fatalf("inherited client version = %q", got)
}
if got := clientProtocol(req); got != "1" {
t.Fatalf("inherited client protocol = %q", got)
}
}
+13 -7
View File
@@ -86,13 +86,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
sess := store.Session{
TokenHash: hashToken(token),
EmbyUserID: auth.User.ID,
EmbyToken: auth.AccessToken,
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
TokenHash: hashToken(token),
EmbyUserID: auth.User.ID,
EmbyToken: auth.AccessToken,
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
ClientVersion: clientVersion(r),
ClientProtocol: clientProtocol(r),
}
if sess.Username == "" {
sess.Username = req.Username
@@ -132,6 +134,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
+154 -24
View File
@@ -1,12 +1,15 @@
package api
import (
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache"
@@ -19,9 +22,8 @@ import (
// single biggest lever on home-screen latency, so keep these tight.
const (
fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
fieldsNextUp = "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
fieldsDetail = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
@@ -34,7 +36,7 @@ type homeResponse struct {
// without touching the TV app. The client renders whatever arrives.
Rows []recommend.Row `json:"rows"`
// The four fixed rows are also sent flat. They are what the client caches for an
// The fixed rows are also sent flat. They are what the client caches for an
// instant cold start, and what the direct-to-Emby path still produces.
ContinueWatching []json.RawMessage `json:"continueWatching"`
NextUp []json.RawMessage `json:"nextUp"`
@@ -64,11 +66,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
cred := credentials(sess)
var (
mu sync.Mutex
failures int
out homeResponse
sonarrRow *recommend.Row
wg sync.WaitGroup
mu sync.Mutex
failures int
out homeResponse
sonarrRow *recommend.Row
forYouRow *recommend.Row
forYouRowStale bool
wg sync.WaitGroup
)
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
@@ -88,20 +92,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}
run(&out.ContinueWatching, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsResumable"},
"IncludeItemTypes": {"Movie,Episode"},
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {itoa(limit)},
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
"Recursive": {"true"},
"MediaTypes": {"Video"},
"Limit": {itoa(limit)},
}, fieldsContinue))
})
run(&out.NextUp, func() (*emby.ItemsResult, error) {
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsNextUp))
})
run(&out.Favorites, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsFavorite"},
@@ -112,6 +108,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
"Limit": {itoa(limit)},
}, fieldsRow))
})
run(&out.NextUp, func() (*emby.ItemsResult, error) {
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsContinue))
})
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"IncludeItemTypes": {"Movie"},
@@ -135,6 +136,48 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
mu.Unlock()
}()
}
if s.forYou != nil {
// The prepared pool is one indexed PostgreSQL read. It runs beside the Emby
// calls and deliberately has no live-engine fallback, so Home can never inherit
// Tracearr fan-out or recommendation rebuild latency.
wg.Add(1)
go func() {
defer wg.Done()
location := s.cfg.SonarrLocation
if location == nil {
location = time.UTC
}
window := homeForYouWindowAt(time.Now().In(location))
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
if err != nil {
s.log.Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
return
}
if !hit || len(prepared) == 0 {
return
}
rowIndex := -1
for i := range prepared {
if prepared[i].ID == "for-you:picks" {
rowIndex = i
break
}
}
if rowIndex < 0 {
return
}
row := prepared[rowIndex]
row.ID = "for-you:home:" + window.ID
row.Title = window.Title
if len(row.Items) > 12 {
row.Items = row.Items[:12]
}
mu.Lock()
forYouRow = &row
forYouRowStale = stale
mu.Unlock()
}()
}
wg.Wait()
@@ -153,9 +196,21 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
s.refreshRecommendationsInBackground(sess)
}
rows := baseRows(out)
nearContinue := make([]recommend.Row, 0, 2)
if forYouRow != nil {
nearContinue = append(nearContinue, *forYouRow)
if forYouRowStale {
s.forYou.MarkDirty(context.WithoutCancel(ctx), sess)
s.forYou.RefreshAsync(sess, false)
}
}
if sonarrRow != nil {
// The schedule is most useful beside Next Up, before personal collections.
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
nearContinue = append(nearContinue, *sonarrRow)
}
if len(nearContinue) > 0 {
// Personalised discovery and today's schedule are most useful immediately
// after Continue Watching, before the broader library collections.
rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
}
out.Rows = append(rows, recommendations...)
@@ -273,7 +328,60 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
writeRaw(w, http.StatusOK, body)
}
// baseRows describes the four fixed rows.
type searchHistoryRequest struct {
Query string `json:"query"`
}
type searchHistoryResponse struct {
Queries []string `json:"queries"`
}
const (
recentSearchDays = 30
recentSearchLimit = 10
)
func (s *Server) handleRecentSearches(w http.ResponseWriter, r *http.Request, sess store.Session) {
if s.store == nil {
writeError(w, http.StatusInternalServerError, "could not load recent searches")
return
}
since := time.Now().Add(-recentSearchDays * 24 * time.Hour)
queries, err := s.store.RecentSearches(
r.Context(),
sess.EmbyUserID,
since,
recentSearchLimit,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load recent searches")
return
}
if queries == nil {
queries = []string{}
}
writeJSON(w, http.StatusOK, searchHistoryResponse{Queries: queries})
}
func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req searchHistoryRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid search history request")
return
}
query := strings.TrimSpace(req.Query)
if len([]rune(query)) < 2 || len([]rune(query)) > 200 {
writeError(w, http.StatusBadRequest, "search query length is invalid")
return
}
if s.store == nil || s.store.RecordSearch(r.Context(), sess.EmbyUserID, query) != nil {
writeError(w, http.StatusInternalServerError, "could not record search")
return
}
w.WriteHeader(http.StatusNoContent)
}
// baseRows describes the three fixed rows.
//
// Titles live here rather than in the app so wording can change server-side. They are
// emitted even when empty: the client draws its own "Nothing in progress" message, and a
@@ -283,7 +391,29 @@ func baseRows(h homeResponse) []recommend.Row {
{ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching},
{ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp},
{ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites},
{ID: "latest-movies", Title: "Recently Added Movies", Kind: "latest", Items: h.LatestMovies},
{ID: "latest-movies", Title: "Recent New Releases", Kind: "latest", Items: h.LatestMovies},
}
}
type homeForYouWindow struct {
ID string
Title string
Minutes int
}
// homeForYouWindowAt keeps Home useful without asking the viewer for a duration.
// These deliberately broad windows suit a household TV: short before lunch, an
// episode-sized pick in the afternoon/late evening, and film headroom at night.
func homeForYouWindowAt(now time.Time) homeForYouWindow {
switch hour := now.Hour(); {
case hour >= 5 && hour < 12:
return homeForYouWindow{ID: "morning", Title: "Quick morning picks for you", Minutes: 30}
case hour >= 12 && hour < 17:
return homeForYouWindow{ID: "afternoon", Title: "An hour for your afternoon", Minutes: 60}
case hour >= 17 && hour < 23:
return homeForYouWindow{ID: "evening", Title: "Tonight's picks for you", Minutes: 120}
default:
return homeForYouWindow{ID: "late-night", Title: "Late-night picks for you", Minutes: 60}
}
}
+76 -5
View File
@@ -1,12 +1,16 @@
package api
import (
"context"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -43,6 +47,9 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
params.Set(key, v)
}
}
if writeNotModifiedForTag(w, r, params.Get("tag")) {
return
}
resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params)
if err != nil {
@@ -61,14 +68,14 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
// stay conservative.
if params.Get("tag") != "" {
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("ETag", imageETag(params.Get("tag")))
} else {
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("image copy failed", "error", err)
}
copyImage(w, r, resp.Body, s.log,
"source", "emby", "item_id", itemID, "image_type", imageType)
}
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
@@ -111,7 +118,71 @@ func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemI
}
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)
copyImage(w, r, resp.Body, s.log,
"source", "sonarr", "item_id", itemID, "image_type", imageType)
}
func copyImage(
w io.Writer,
r *http.Request,
body io.Reader,
log *slog.Logger,
attributes ...any,
) {
written, err := io.Copy(w, body)
if err == nil {
return
}
if expectedClientDisconnect(r, err) {
// Image loaders cancel work aggressively as cards leave the viewport. That is a
// successful resource-saving decision by the TV, not an unhealthy gateway.
return
}
fields := append([]any{"bytes_written", written, "error", err}, attributes...)
log.Warn("image stream interrupted", fields...)
}
func expectedClientDisconnect(r *http.Request, err error) bool {
if r.Context().Err() != nil ||
errors.Is(err, context.Canceled) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.EPIPE) ||
errors.Is(err, syscall.ECONNRESET) {
return true
}
message := strings.ToLower(err.Error())
for _, fragment := range []string{
"broken pipe",
"connection reset by peer",
"client disconnected",
"request canceled",
"request cancelled",
"stream closed",
} {
if strings.Contains(message, fragment) {
return true
}
}
return false
}
func writeNotModifiedForTag(w http.ResponseWriter, r *http.Request, tag string) bool {
if tag == "" {
return false
}
etag := imageETag(tag)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
for _, candidate := range strings.Split(r.Header.Get("If-None-Match"), ",") {
if strings.TrimSpace(candidate) == etag || strings.TrimSpace(candidate) == "*" {
w.WriteHeader(http.StatusNotModified)
return true
}
}
return false
}
func imageETag(tag string) string {
// Emby image tags are normally hex, but quote defensively for a valid HTTP entity tag.
return `"` + strings.NewReplacer(`\`, "", `"`, "").Replace(tag) + `"`
}
+57 -1
View File
@@ -3,11 +3,16 @@ package api
import (
"encoding/json"
"net/http"
"net/url"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
type seriesEpisodesResponse struct {
Items []json.RawMessage `json:"items"`
}
type flagRequest struct {
Value bool `json:"value"`
}
@@ -21,7 +26,9 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "item:"+itemID)
// Version the entry when the detail contract grows so older cached payloads cannot
// hide newly requested fields such as People.
key := cache.UserKey(sess.EmbyUserID, "item:v2:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
@@ -41,6 +48,51 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeRaw(w, http.StatusOK, item)
}
// handleSeriesEpisodes supplies the complete episode browser in one cached response.
// The TV groups by ParentIndexNumber locally, so changing seasons never reaches Emby.
func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
seriesID := r.PathValue("id")
if seriesID == "" {
writeError(w, http.StatusBadRequest, "series id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "series-episodes:"+seriesID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
result, err := s.emby.Episodes(ctx, credentials(sess), seriesID, url.Values{
"UserId": {sess.EmbyUserID},
"Fields": {"Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"},
"EnableUserData": {"true"},
"EnableImages": {"true"},
"EnableImageTypes": {"Primary,Thumb,Backdrop"},
"ImageTypeLimit": {"1"},
"Limit": {"1000"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load series episodes")
return
}
items := result.Items
if items == nil {
items = []json.RawMessage{}
}
body, err := json.Marshal(seriesEpisodesResponse{Items: items})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode series episodes")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.log.Warn("series episodes cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// handleTrailer answers with the item's first local trailer, or 404 when it has none.
// The screensaver's Play action uses this before asking for a playback URL.
func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -100,5 +152,9 @@ func (s *Server) setFlag(
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("cache invalidation failed", "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
writeRaw(w, http.StatusOK, userData)
}
+20 -3
View File
@@ -89,14 +89,31 @@ 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) {
// It also carries informational alerts, because this poll is the one thing the app is
// already listening to — a push channel would be a second connection for a banner.
func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ store.Session) {
state := s.maintenance.get()
message := state.Message
if state.Enabled && message == "" {
message = store.DefaultMaintenanceMessage
}
alerts := []clientAlert{}
// Nothing to celebrate while the service is down, and the client is showing the
// maintenance screen anyway.
if !state.Enabled {
if found := s.sonarrAiredAlerts(r.Context()); len(found) > 0 {
alerts = found
}
}
compatible, compatibilityMessage := compatibilityFor(r)
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"message": message,
"maintenance": state.Enabled,
"message": message,
"alerts": alerts,
"compatible": compatible,
"compatibilityMessage": compatibilityMessage,
"clientVersion": clientVersion(r),
"clientProtocol": clientProtocol(r),
"serverProtocol": membyProtocolVersion,
})
}
+288 -10
View File
@@ -15,16 +15,51 @@ import (
const ticksPerMillisecond = 10_000
type playbackResponse struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
ItemID string `json:"itemId"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
}
type playableSubtitle struct {
ID string `json:"id"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
Language string `json:"language,omitempty"`
Label string `json:"label,omitempty"`
IsDefault bool `json:"isDefault"`
IsForced bool `json:"isForced"`
IsHearingImpaired bool `json:"isHearingImpaired"`
DeliveryMethod string `json:"deliveryMethod"`
Codec string `json:"codec,omitempty"`
}
type playbackReport struct {
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
IsPaused bool `json:"isPaused"`
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
IsPaused bool `json:"isPaused"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
EventName string `json:"eventName,omitempty"`
}
// nextEpisodeResponse carries the episode that follows the one being watched. Item is
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so
// the client decodes it into the same BaseItem it uses everywhere else.
type nextEpisodeResponse struct {
Item json.RawMessage `json:"item"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
}
// handlePlayback resolves what to actually play.
@@ -74,11 +109,28 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
}
var subtitleIndex *int
if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 {
subtitleIndex = &parsed
}
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
)
streamURL := s.emby.StreamURL(cred, target.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
URL: s.emby.StreamURL(cred, target.ID),
URL: streamURL,
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
@@ -139,6 +191,225 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
return &summary, nil
}
// handleNextEpisode resolves the episode that follows the one being watched, so the player
// can offer a "next up" countdown without the TV needing to know how Emby orders a series.
//
// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an
// unreadable series all come back as 404 and the client simply shows no banner.
func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
cred := credentials(sess)
// The client already knows which series it launched, so accepting it as a hint keeps
// this off Emby for one round trip. Older clients omit it and we look it up.
seriesID := strings.TrimSpace(r.URL.Query().Get("seriesId"))
if seriesID == "" {
raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
var parsed struct {
SeriesID string `json:"SeriesId"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
}
seriesID = parsed.SeriesID
}
if seriesID == "" {
writeError(w, http.StatusNotFound, "this item is not part of a series")
return
}
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
"AdjacentTo": {itemID},
"Fields": {"RunTimeTicks,Overview,SeriesName"},
"EnableUserData": {"true"},
"EnableImageTypes": {"Primary,Thumb"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load the next episode")
return
}
raw, next, ok := episodeAfter(episodes.Items, itemID)
if !ok {
writeError(w, http.StatusNotFound, "no episode follows this one")
return
}
title := next.Name
if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
title = series + " " + title
}
subtitles, mediaSourceID, playSessionID, _, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "",
)
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
URL: s.emby.StreamURL(cred, next.ID),
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
func (s *Server) playbackSubtitles(
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
subtitleIndex *int, currentPlaySessionID string,
) ([]playableSubtitle, string, string, string, string) {
info, err := s.emby.PlaybackInfo(
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID,
)
if err != nil {
s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
}
if len(info.MediaSources) == 0 {
return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
}
out := make([]playableSubtitle, 0)
seen := make(map[string]bool)
source := info.MediaSources[0]
for _, stream := range source.MediaStreams {
if !strings.EqualFold(stream.Type, "Subtitle") || stream.Index < 0 {
continue
}
method := stream.DeliveryMethod
if method == "" {
if stream.IsTextSubtitleStream {
method = "External"
} else {
method = "Encode"
}
}
delivery := ""
mimeType := subtitleMIME(stream.Codec, stream.DeliveryURL)
if strings.EqualFold(method, "External") && mimeType != "" {
if stream.DeliveryURL != "" {
delivery = s.emby.DeliveryURL(cred, stream.DeliveryURL)
} else {
delivery = s.emby.SubtitleURL(cred, itemID, source.ID, stream.Index, subtitleExtension(stream.Codec))
}
}
key := strconv.Itoa(stream.Index)
if seen[key] {
continue
}
seen[key] = true
label := strings.TrimSpace(stream.DisplayTitle)
if label == "" {
label = strings.TrimSpace(stream.Title)
}
out = append(out, playableSubtitle{
ID: strconv.Itoa(stream.Index),
URL: delivery,
MimeType: mimeType,
Language: strings.TrimSpace(stream.Language),
Label: label,
IsDefault: stream.IsDefault,
IsForced: stream.IsForced,
IsHearingImpaired: stream.IsHearingImpaired ||
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "sdh") ||
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "hearing"),
DeliveryMethod: method,
Codec: stream.Codec,
})
}
negotiatedURL := ""
playMethod := "DirectPlay"
if subtitleIndex != nil && source.TranscodingURL != "" {
negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL)
playMethod = "Transcode"
}
return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod
}
func subtitleExtension(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "subrip":
return "srt"
case "webvtt":
return "vtt"
case "tx3g":
return "mov_text"
default:
if strings.TrimSpace(codec) == "" {
return "vtt"
}
return strings.ToLower(strings.TrimSpace(codec))
}
}
func subtitleMIME(codec, delivery string) string {
value := strings.ToLower(strings.TrimSpace(codec))
if value == "" {
path := delivery
if parsed, err := url.Parse(delivery); err == nil {
path = parsed.Path
}
if dot := strings.LastIndex(path, "."); dot >= 0 {
value = strings.ToLower(path[dot+1:])
}
}
switch value {
case "srt", "subrip":
return "application/x-subrip"
case "vtt", "webvtt":
return "text/vtt"
case "ass", "ssa":
return "text/x-ssa"
case "ttml", "dfxp":
return "application/ttml+xml"
case "tx3g", "mov_text":
return "application/x-quicktime-tx3g"
default:
return ""
}
}
// episodeAfter picks the episode following currentID out of an AdjacentTo result, which
// Emby returns in running order as [previous, current, next] minus whichever ends do not
// exist — so the position of the current episode is what identifies the next one, not the
// length of the list.
func episodeAfter(items []json.RawMessage, currentID string) (json.RawMessage, emby.Summary, bool) {
for i, raw := range items {
summary, err := emby.Summarise(raw)
if err != nil || summary.ID != currentID {
continue
}
if i+1 >= len(items) {
return nil, emby.Summary{}, false
}
next, err := emby.Summarise(items[i+1])
if err != nil || next.ID == "" {
return nil, emby.Summary{}, false
}
return items[i+1], next, true
}
return nil, emby.Summary{}, false
}
func seriesNameOf(raw json.RawMessage) string {
var parsed struct {
SeriesName string `json:"SeriesName"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return ""
}
return parsed.SeriesName
}
// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache
// so Continue Watching reflects the new position on the next home load.
func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -160,8 +431,11 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
return
}
err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused)
err := s.emby.ReportPlayback(
r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
)
if err != nil {
// A dropped progress report is not worth failing playback over; log and accept.
s.log.Warn("playback report failed", "phase", phase, "error", err)
@@ -176,6 +450,10 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("recommendation invalidation failed", "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
}
w.WriteHeader(http.StatusNoContent)
}
+40
View File
@@ -124,6 +124,46 @@ func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, s
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
// handleForYou is deliberately separate from /home. Tracearr and the richer scoring
// path may take longer than a launcher request, and the chosen time budget is local to
// this visit. The TV only calls this when the viewer enters the dedicated area.
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store.Session) {
minutes := queryInt(r, "minutes", 0, 360)
if s.forYou != nil && r.URL.Query().Get("refresh") != "1" {
rows, hit, stale, err := s.forYou.PreparedRows(r.Context(), sess, minutes)
if err != nil {
s.log.Warn("prepared For You read failed; using live fallback",
"user", sess.EmbyUserID, "error", err)
} else if hit {
w.Header().Set("X-Memby-For-You", "prepared")
if stale {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
return
} else {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
}
buildCtx, cancel := context.WithTimeout(r.Context(), s.cfg.RecommendTimeout)
defer cancel()
rows, err := s.recommender.BuildForYou(
buildCtx,
credentials(sess),
sess.Username,
recommend.ForYouOptions{AvailableMinutes: minutes},
)
if err != nil {
s.writeUpstreamError(w, err, "could not build For You recommendations")
return
}
w.Header().Set("X-Memby-For-You", "live-fallback")
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
func nonNilRows(rows []recommend.Row) []recommend.Row {
if rows == nil {
return []recommend.Row{}
+22
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -11,6 +12,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
// membyProtocolVersion changes only when the client/server wire contract is no longer
// mutually compatible. App release versions remain independent and are handled by the
// update policy.
const membyProtocolVersion = 1
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful.
type updatePolicyCache struct {
@@ -63,6 +69,22 @@ func clientVersion(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Version"))
}
func clientProtocol(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Protocol"))
}
func compatibilityFor(r *http.Request) (bool, string) {
reported, err := strconv.Atoi(clientProtocol(r))
if err != nil || reported != membyProtocolVersion {
if clientProtocol(r) == "" {
return false, "This Memby app is too old to verify compatibility with the server. Update the app."
}
return false, "Memby app/server mismatch: app protocol " + clientProtocol(r) +
", server protocol " + strconv.Itoa(membyProtocolVersion) + ". Update the app or server."
}
return true, ""
}
// handleUpdate answers the client's version check.
//
// Its own endpoint rather than a field on /v1/home: the home payload is cached per user,
+1 -1
View File
@@ -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:v2", userID) }
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) }
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
return c.Delete(ctx, RecommendationsKey(userID))
+41 -10
View File
@@ -78,6 +78,26 @@ type Config struct {
SonarrAPIKey string
SonarrTTL time.Duration
SonarrLocation *time.Location
// SonarrAlertWindow is how long after an episode airs the "aired, coming soon"
// banner keeps being offered to clients. Zero turns the banners off without
// touching the airing-today row.
SonarrAlertWindow time.Duration
// Tracearr is an optional, read-only source of completion, session-length and
// direct-play signals for the per-user For You area. The public API key stays in
// the gateway and is never returned to a TV.
TracearrURL string
TracearrAPIKey string
TracearrServerID string
// TracearrSyncInterval imports recent changed sessions. FullInterval reconciles
// late/out-of-order updates and deletions without needing a source cursor.
TracearrSyncInterval time.Duration
TracearrFullInterval time.Duration
// ForYouMinRebuildAge coalesces bursts of playback/library changes. RefreshInterval
// is the acceptable age of a prepared pool before it is refreshed.
ForYouMinRebuildAge time.Duration
ForYouRefreshInterval time.Duration
}
func Load() (Config, error) {
@@ -104,16 +124,24 @@ func Load() (Config, error) {
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),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
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),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
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),
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 10*time.Minute),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 30*time.Minute),
}
if c.EmbyURL == "" {
@@ -134,6 +162,9 @@ func Load() (Config, error) {
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
}
if (c.TracearrURL == "") != (c.TracearrAPIKey == "") {
return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_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)
+200 -2
View File
@@ -47,6 +47,14 @@ type AuthResult struct {
ServerID string `json:"ServerId"`
}
type User struct {
ID string `json:"Id"`
Name string `json:"Name"`
Policy struct {
IsDisabled bool `json:"IsDisabled"`
} `json:"Policy"`
}
// Summary is the minimal view of an item the gateway needs for its own logic.
type Summary struct {
ID string `json:"Id"`
@@ -57,6 +65,35 @@ type Summary struct {
} `json:"UserData"`
}
type PlaybackInfo struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionID string `json:"PlaySessionId"`
}
type MediaSourceInfo struct {
ID string `json:"Id"`
MediaStreams []MediaStream `json:"MediaStreams"`
DirectStreamURL string `json:"DirectStreamUrl"`
TranscodingURL string `json:"TranscodingUrl"`
}
type MediaStream struct {
Index int `json:"Index"`
Type string `json:"Type"`
Codec string `json:"Codec"`
Title string `json:"Title"`
DisplayTitle string `json:"DisplayTitle"`
Language string `json:"Language"`
IsDefault bool `json:"IsDefault"`
IsForced bool `json:"IsForced"`
IsHearingImpaired bool `json:"IsHearingImpaired"`
IsExternal bool `json:"IsExternal"`
IsTextSubtitleStream bool `json:"IsTextSubtitleStream"`
SupportsExternalStream bool `json:"SupportsExternalStream"`
DeliveryURL string `json:"DeliveryUrl"`
DeliveryMethod string `json:"DeliveryMethod"`
}
// APIError carries an upstream Emby status code so handlers can mirror it.
type APIError struct {
StatusCode int
@@ -115,6 +152,20 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error {
return c.do(req, nil)
}
// Users returns the household accounts visible to an administrative/service token.
// It is used only by the background For You builder, never on a television request.
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/Users", nil, cred, nil)
if err != nil {
return nil, err
}
var users []User
if err := c.do(req, &users); err != nil {
return nil, err
}
return users, 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)
}
@@ -167,6 +218,93 @@ func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields stri
return raw, nil
}
func (c *Client) PlaybackInfo(
ctx context.Context,
cred Credentials,
itemID string,
startTicks int64,
subtitleStreamIndex *int,
currentPlaySessionID string,
) (*PlaybackInfo, error) {
params := url.Values{
"UserId": {cred.UserID},
"IsPlayback": {"true"},
}
body, err := json.Marshal(map[string]any{
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
"StartTimeTicks": startTicks,
"DeviceProfile": map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
"VideoCodec": "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
"AudioCodec": "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
"Type": "Video",
},
},
"TranscodingProfiles": []map[string]string{
{
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
},
"SubtitleProfiles": []map[string]string{
{"Format": "srt", "Method": "External"},
{"Format": "subrip", "Method": "External"},
{"Format": "ass", "Method": "External"},
{"Format": "ssa", "Method": "External"},
{"Format": "vtt", "Method": "External"},
{"Format": "webvtt", "Method": "External"},
{"Format": "mov_text", "Method": "External"},
{"Format": "tx3g", "Method": "External"},
{"Format": "pgs", "Method": "Encode"},
{"Format": "pgssub", "Method": "Encode"},
{"Format": "sup", "Method": "Encode"},
{"Format": "vobsub", "Method": "Encode"},
{"Format": "dvdsub", "Method": "Encode"},
},
},
})
var requestBody map[string]any
if err == nil {
err = json.Unmarshal(body, &requestBody)
}
if subtitleStreamIndex != nil {
requestBody["SubtitleStreamIndex"] = *subtitleStreamIndex
}
if currentPlaySessionID != "" {
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
}
if err == nil {
body, err = json.Marshal(requestBody)
}
if err != nil {
return nil, err
}
req, err := c.newRequest(
ctx,
http.MethodPost,
"/Items/"+url.PathEscape(itemID)+"/PlaybackInfo",
params,
cred,
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
var out PlaybackInfo
if err := c.do(req, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
}
// SetFavorite/SetPlayed return Emby's resulting UserData verbatim.
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
method := http.MethodDelete
@@ -187,7 +325,13 @@ func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string,
}
// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error {
func (c *Client) ReportPlayback(
ctx context.Context,
cred Credentials,
phase, itemID, mediaSourceID, playSessionID, playMethod, eventName string,
positionTicks int64,
isPaused bool,
) error {
var path string
switch phase {
case "started":
@@ -202,12 +346,22 @@ func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase str
body, err := json.Marshal(map[string]any{
"ItemId": itemID,
"MediaSourceId": mediaSourceID,
"PlaySessionId": playSessionID,
"PositionTicks": positionTicks,
"IsPaused": isPaused,
"IsMuted": false,
"CanSeek": true,
"PlayMethod": "DirectPlay",
"PlayMethod": playMethod,
})
if phase == "progress" {
var fields map[string]any
if err := json.Unmarshal(body, &fields); err != nil {
return err
}
fields["EventName"] = eventName
body, err = json.Marshal(fields)
}
if err != nil {
return err
}
@@ -249,6 +403,50 @@ func (c *Client) StreamURL(cred Credentials, itemID string) string {
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
}
// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
func (c *Client) DeliveryURL(cred Credentials, delivery string) string {
delivery = strings.TrimSpace(delivery)
if delivery == "" {
return ""
}
var resolved string
if parsed, err := url.Parse(delivery); err == nil && parsed.IsAbs() {
resolved = parsed.String()
} else {
resolved = c.publicURL + "/" + strings.TrimLeft(delivery, "/")
}
parsed, err := url.Parse(resolved)
if err != nil {
return resolved
}
query := parsed.Query()
if query.Get("api_key") == "" {
query.Set("api_key", cred.Token)
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
// SubtitleURL uses Emby's stable subtitle download route rather than the optional
// MediaStream.DeliveryUrl. VTT normalizes every text subtitle codec before it reaches
// the TV.
func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, index int, extensions ...string) string {
if mediaSourceID == "" {
mediaSourceID = itemID
}
extension := "vtt"
if len(extensions) > 0 && strings.TrimSpace(extensions[0]) != "" {
extension = extensions[0]
}
path := fmt.Sprintf(
"/Videos/%s/%s/Subtitles/%d/Stream.%s",
url.PathEscape(itemID),
url.PathEscape(mediaSourceID),
index, url.PathEscape(extension),
)
return c.DeliveryURL(cred, path)
}
// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
+23 -2
View File
@@ -56,14 +56,22 @@ type Syncer struct {
// is borrowed instead.
serviceCred emby.Credentials
mu sync.Mutex
running bool
mu sync.Mutex
running bool
afterSync func()
}
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
return &Syncer{emby: embyClient, store: st, serviceCred: serviceCred, log: log}
}
// SetAfterSync installs the inexpensive invalidation callback used by derived data.
func (s *Syncer) SetAfterSync(callback func()) {
s.mu.Lock()
defer s.mu.Unlock()
s.afterSync = callback
}
// Result summarises one import.
type Result struct {
Kind string `json:"kind"`
@@ -150,6 +158,12 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
"kind", kind, "trigger", trigger, "seen", result.Seen,
"upserted", result.Upserted, "removed", result.Removed,
"duration", result.Duration.Round(time.Millisecond))
s.mu.Lock()
afterSync := s.afterSync
s.mu.Unlock()
if afterSync != nil {
afterSync()
}
return result, nil
}
@@ -345,5 +359,12 @@ func searchText(parsed syncItem) string {
parts = append(parts, strconv.Itoa(*parsed.ProductionYear))
}
parts = append(parts, parsed.Genres...)
// Studios too, so "A24" or "Pixar" finds a shelf's worth of titles. Existing rows
// keep their old text until the next full import rewrites them.
for _, studio := range parsed.Studios {
if studio.Name != "" {
parts = append(parts, studio.Name)
}
}
return strings.Join(parts, " ")
}
+17
View File
@@ -70,6 +70,23 @@ func TestSearchTextIncludesSeriesNameSoEpisodesAreFindable(t *testing.T) {
}
}
func TestSearchTextIncludesStudiosSoAStudioNameFindsItsTitles(t *testing.T) {
text := searchText(syncItem{
Name: "Everything Everywhere All at Once",
Type: "Movie",
Studios: []struct {
Name string `json:"Name"`
}{{Name: "A24"}, {Name: ""}},
})
if !strings.Contains(text, "A24") {
t.Fatalf("search text %q is missing the studio", text)
}
if strings.Contains(text, " ") {
t.Fatalf("an unnamed studio should be skipped, not joined as a gap: %q", text)
}
}
func TestSearchTextDoesNotRepeatTheTitleForAMovie(t *testing.T) {
text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"})
+500 -19
View File
@@ -4,13 +4,16 @@ import (
"context"
"encoding/json"
"log/slog"
"math"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
// Row is one horizontal strip on the TV home screen.
@@ -28,6 +31,13 @@ type Source interface {
Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error)
}
// NextUpSource is optional because the on-demand engine and small test sources do not
// need it. The production Emby client implements it; prepared rebuilds use one Next Up
// request to prove that an abandoned programme still has an unwatched episode.
type NextUpSource interface {
NextUp(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
}
// LibrarySource is the imported catalogue. When present, the candidate pool comes from
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
type LibrarySource interface {
@@ -44,15 +54,29 @@ type CuratedLibrarySource interface {
) ([]json.RawMessage, error)
}
type TracearrSource interface {
History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
}
type BehaviorSource interface {
BrowsingCandidates(
ctx context.Context,
userID string,
since time.Time,
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
ID string
Title string
Kind string
ItemTypes []string
Genres []string
Studios []string
RequireAffinity bool
}
type Engine struct {
@@ -60,7 +84,9 @@ type Engine struct {
log *slog.Logger
// Library is optional; nil (or an empty library) falls back to querying Emby.
Library LibrarySource
Library LibrarySource
Tracearr TracearrSource
Behavior BehaviorSource
// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
// looks broken next to full rows, so short rows are dropped entirely.
@@ -72,6 +98,333 @@ type Engine struct {
CuratedRows []CuratedRow
}
// ForYouOptions are request-scoped constraints chosen on the television.
type ForYouOptions struct {
// AvailableMinutes is zero for no time limit.
AvailableMinutes int
}
type compatibilityProfile struct {
directCodecs map[string]int
transcodeCodecs map[string]int
}
// BuildForYou creates the dedicated, explainable recommendation area. Emby supplies
// catalogue metadata, Tracearr supplies completion and real device/playback outcomes,
// and Memby's own row analytics supplies browsing intent.
func (e *Engine) BuildForYou(
ctx context.Context,
cred emby.Credentials,
username string,
options ForYouOptions,
) ([]Row, error) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return nil, err
}
profile := BuildProfile(history, favorites)
var sessions []tracearr.Session
if e.Tracearr != nil {
if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
} else {
sessions = recommendationSessions(fetched)
e.applyTracearrSignals(&profile, history, sessions)
}
}
browsed := map[string]bool{}
if e.Behavior != nil {
raws, browseErr := e.Behavior.BrowsingCandidates(
ctx,
cred.UserID,
time.Now().Add(-30*24*time.Hour),
30,
)
if browseErr != nil {
e.log.Warn("browsing signals unavailable", "error", browseErr)
} else {
for i, item := range Decode(raws) {
weight := 0.55 * powDecay(0.92, i)
profile.absorbTaste(item, weight)
browsed[item.ID] = true
}
}
}
genres := profile.TopGenres(4)
if len(genres) == 0 {
return []Row{}, nil
}
candidates, ok := e.libraryCandidatesForYou(ctx, cred, genres)
if !ok {
return []Row{}, nil
}
compatibility := buildCompatibilityProfile(sessions)
items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
if len(items) < e.MinRowItems {
return []Row{}, nil
}
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
reason, compatibilityLabel := explainRecommendation(
profile,
item,
options.AvailableMinutes,
compatibility,
browsed[item.ID],
)
raws = append(raws, enrichRecommendation(item.Raw, reason, compatibilityLabel))
}
title := "Top picks for you"
if options.AvailableMinutes > 0 {
title = "Top picks that fit in " + strconv.Itoa(options.AvailableMinutes) + " minutes"
}
return []Row{{
ID: "for-you:picks",
Title: title,
Kind: "for-you",
Items: raws,
}}, nil
}
func powDecay(base float64, position int) float64 {
return math.Pow(base, float64(position))
}
func (e *Engine) applyTracearrSignals(
profile *Profile,
history []Item,
sessions []tracearr.Session,
) {
byTitle := make(map[string]Item, len(history))
for _, item := range history {
byTitle[item.TitleKey()] = item
}
for i, session := range sessions {
if session.Completion() > 0 {
profile.SeenTitles[tracearrSeenKey(session)] = true
}
item, ok := byTitle[session.TitleKey()]
if !ok {
continue
}
// Completion distinguishes "finished it twice" from "abandoned after ten
// minutes"; recency lets changing tastes move promptly.
weight := (0.2 + session.Completion()) * powDecay(0.985, i)
profile.absorbTaste(item, weight)
}
}
func (e *Engine) libraryCandidatesForYou(
ctx context.Context,
cred emby.Credentials,
genres []string,
) ([]Item, bool) {
if e.Library != nil {
raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*12)
if err == nil && len(raws) > 0 {
return Decode(raws), true
}
if err != nil {
e.log.Warn("for-you library candidates failed; falling back to emby", "error", err)
}
}
result, err := e.source.Items(ctx, cred, url.Values{
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"Filters": {"IsUnplayed"},
"Genres": {strings.Join(genres, "|")},
"SortBy": {"CommunityRating"},
"SortOrder": {"Descending"},
"Limit": {strconv.Itoa(e.RowSize * 8)},
"Fields": {candidateFields + ",MediaStreams,Container"},
"ImageTypeLimit": {"1"},
"EnableImages": {"true"},
"EnableImageTypes": {rowImageTypes},
"EnableUserData": {"true"},
})
if err != nil {
e.log.Warn("for-you emby candidates failed", "error", err)
return nil, false
}
return Decode(result.Items), len(result.Items) > 0
}
func buildCompatibilityProfile(sessions []tracearr.Session) compatibilityProfile {
profile := compatibilityProfile{
directCodecs: map[string]int{},
transcodeCodecs: map[string]int{},
}
for _, session := range sessions {
if !session.IsTelevisionSession() {
continue
}
for _, codec := range []string{session.SourceVideoCodec, session.SourceAudioCodec} {
codec = strings.ToLower(strings.TrimSpace(codec))
if codec == "" {
continue
}
if session.IsTranscode ||
strings.EqualFold(session.VideoDecision, "transcode") ||
strings.EqualFold(session.AudioDecision, "transcode") {
profile.transcodeCodecs[codec]++
} else {
profile.directCodecs[codec]++
}
}
}
return profile
}
func compatibilityScore(item Item, profile compatibilityProfile) float64 {
var score float64
var known int
for _, stream := range item.MediaStreams {
if !strings.EqualFold(stream.Type, "Video") && !strings.EqualFold(stream.Type, "Audio") {
continue
}
codec := strings.ToLower(strings.TrimSpace(stream.Codec))
direct, transcode := profile.directCodecs[codec], profile.transcodeCodecs[codec]
if direct+transcode == 0 {
continue
}
known++
score += float64(direct-transcode) / float64(direct+transcode)
}
if known == 0 {
return 0
}
return score / float64(known)
}
func rankForYou(
profile Profile,
candidates []Item,
availableMinutes int,
compatibility compatibilityProfile,
limit int,
) []Item {
type scored struct {
item Item
score float64
}
ranked := make([]scored, 0, len(candidates))
seen := map[string]bool{}
for _, candidate := range candidates {
if seen[candidate.ID] {
continue
}
seen[candidate.ID] = true
base := profile.Score(candidate)
if base < 0 {
continue
}
runtime := candidate.RuntimeMinutes()
if availableMinutes > 0 && (runtime <= 0 || runtime > availableMinutes) {
continue
}
score := base + compatibilityScore(candidate, compatibility)*1.4
if availableMinutes > 0 {
// Prefer a satisfying fit over something dramatically shorter, without
// allowing runtime to overwhelm taste.
score += float64(runtime) / float64(availableMinutes) * 0.35
}
ranked = append(ranked, scored{item: candidate, score: score})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].item.Name < ranked[j].item.Name
})
if limit > 0 && len(ranked) > limit {
ranked = ranked[:limit]
}
out := make([]Item, 0, len(ranked))
for _, entry := range ranked {
out = append(out, entry.item)
}
return out
}
func explainRecommendation(
profile Profile,
item Item,
availableMinutes int,
compatibility compatibilityProfile,
browsed bool,
) (string, string) {
top := profile.TopGenres(5)
matched := ""
for _, wanted := range top {
for _, genre := range item.Genres {
if strings.EqualFold(wanted, genre) {
matched = genre
break
}
}
if matched != "" {
break
}
}
reasons := make([]string, 0, 3)
if browsed {
reasons = append(reasons, "You explored this recently")
} else if matched != "" {
reasons = append(reasons, "Matches your "+matched+" viewing")
} else if len(profile.Seeds) > 0 {
reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name)
} else {
reasons = append(reasons, "Matches your recent viewing")
}
if availableMinutes > 0 && item.RuntimeMinutes() > 0 {
reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window")
}
compatibilityLabel := ""
switch score := compatibilityScore(item, compatibility); {
case score > 0.2:
compatibilityLabel = "Direct plays well on this TV"
reasons = append(reasons, compatibilityLabel)
case score < -0.2:
compatibilityLabel = "May need transcoding on this TV"
default:
compatibilityLabel = "TV compatibility not yet learned"
}
return strings.Join(reasons, " · "), compatibilityLabel
}
func enrichRecommendation(raw json.RawMessage, reason, compatibility string) json.RawMessage {
var item map[string]any
if err := json.Unmarshal(raw, &item); err != nil {
return raw
}
item["MembyRecommendationReason"] = reason
item["MembyCompatibility"] = compatibility
enriched, err := json.Marshal(item)
if err != nil {
return raw
}
return enriched
}
// EnrichPreparedRecommendation adds request-time details to a candidate selected from
// the prepared PostgreSQL pool. The stored reason remains stable; only the viewer's
// current time budget is appended here.
func EnrichPreparedRecommendation(
raw json.RawMessage,
reason, compatibility string,
availableMinutes int,
) json.RawMessage {
if availableMinutes > 0 {
reason += " · fits your " + strconv.Itoa(availableMinutes) + "-minute window"
}
return enrichRecommendation(raw, reason, compatibility)
}
func NewEngine(source Source, log *slog.Logger) *Engine {
return &Engine{
source: source,
@@ -89,22 +442,81 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
},
{
ID: "curated:drama-shows",
Title: "Drama TV Shows",
Title: "Drama Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Drama"},
},
{
ID: "curated:comedy-shows",
Title: "Comedy TV Shows",
Title: "Comedy Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Comedy"},
},
{
ID: "curated:horror-shows",
Title: "Horror Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Horror"},
},
// Movie shelves are deliberately numerous definitions but sparse output:
// only the user's six strongest matching genres and three strongest studio
// families survive buildCuratedRows.
movieGenreRow("action", "Action"),
movieGenreRow("adventure", "Adventure"),
movieGenreRow("animation", "Animation"),
movieGenreRow("comedy", "Comedy"),
movieGenreRow("crime", "Crime"),
movieGenreRow("documentary", "Documentary"),
movieGenreRow("drama", "Drama"),
movieGenreRow("family", "Family"),
movieGenreRow("fantasy", "Fantasy"),
movieGenreRow("horror", "Horror"),
movieGenreRow("mystery", "Mystery"),
movieGenreRow("romance", "Romance"),
movieGenreRow("science-fiction", "Science Fiction"),
movieGenreRow("thriller", "Thriller"),
movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
movieStudioRow(
"disney", "Disney",
"Disney", "Walt Disney Pictures", "Walt Disney Animation Studios",
),
movieStudioRow("marvel", "Marvel Studios", "Marvel Studios"),
movieStudioRow("lucasfilm", "Lucasfilm", "Lucasfilm", "Lucasfilm Ltd."),
movieStudioRow(
"dreamworks", "DreamWorks",
"DreamWorks", "DreamWorks Pictures", "DreamWorks Animation",
),
movieStudioRow(
"warner-bros", "Warner Bros.",
"Warner Bros.", "Warner Bros. Pictures", "Warner Brothers",
),
movieStudioRow(
"universal", "Universal",
"Universal Pictures", "Universal Studios",
),
movieStudioRow("a24", "A24", "A24"),
movieStudioRow("studio-ghibli", "Studio Ghibli", "Studio Ghibli"),
},
}
}
func movieGenreRow(id, genre string) CuratedRow {
return CuratedRow{
ID: "curated:movies:genre:" + id, Title: genre + " Movies", Kind: "movies",
ItemTypes: []string{"Movie"}, Genres: []string{genre}, RequireAffinity: true,
}
}
func movieStudioRow(id, title string, studios ...string) CuratedRow {
return CuratedRow{
ID: "curated:movies:studio:" + id, Title: "More from " + title, Kind: "movies",
ItemTypes: []string{"Movie"}, Studios: studios, RequireAffinity: true,
}
}
const (
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
@@ -152,11 +564,22 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
definitions = append(definitions, rankedDefinition{
affinity := profile.CollectionAffinity(definition.Genres, definition.Studios)
// Studio signals are intentionally damped while scoring individual titles.
// Restore enough weight at shelf level for a genuinely followed studio to earn
// a row before genre shelves consume all of its films during deduplication.
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
affinity *= 3
}
ranked := rankedDefinition{
definition: definition,
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
affinity: affinity,
order: order,
})
}
if definition.RequireAffinity && ranked.affinity <= 0 {
continue
}
definitions = append(definitions, ranked)
}
sort.SliceStable(definitions, func(i, j int) bool {
if definitions[i].affinity != definitions[j].affinity {
@@ -166,8 +589,23 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
})
rows := make([]Row, 0, len(definitions))
// A series may carry several genres. Give it to the user's highest-affinity shelf
// only, so scrolling Shows never reveals the same card again under another label.
seenItems := make(map[string]struct{})
movieGenreRows := 0
movieStudioRows := 0
for _, ranked := range definitions {
definition := ranked.definition
switch {
case strings.HasPrefix(definition.ID, "curated:movies:genre:"):
if movieGenreRows >= 6 {
continue
}
case strings.HasPrefix(definition.ID, "curated:movies:studio:"):
if movieStudioRows >= 3 {
continue
}
}
raws, err := library.CuratedCandidates(
ctx,
definition.ItemTypes,
@@ -179,16 +617,35 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
continue
}
items := RankCollection(profile, Decode(raws), e.RowSize)
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
items := make([]Item, 0, e.RowSize)
for _, item := range rankedItems {
if _, seen := seenItems[item.ID]; seen {
continue
}
items = append(items, item)
if len(items) == e.RowSize {
break
}
}
if len(items) < e.MinRowItems {
continue
}
for _, item := range items {
seenItems[item.ID] = struct{}{}
}
rows = append(rows, Row{
ID: definition.ID,
Title: definition.Title,
Kind: definition.Kind,
Items: Raws(items),
})
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
movieGenreRows++
}
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
movieStudioRows++
}
}
return rows
}
@@ -227,18 +684,20 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {"20"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
// Imported catalogue rows have no user state, so this query is also the
// exclusion set. Household libraries are small enough to fetch it completely.
"Limit": {"5000"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
})
fetch(&played, url.Values{
"Filters": {"IsPlayed"},
"IncludeItemTypes": {"Movie,Episode"},
"IncludeItemTypes": {"Movie,Episode,Series"},
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {"60"},
"Limit": {"5000"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
@@ -261,6 +720,28 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
return append(resumable, played...), favorites, nil
}
func recommendationSessions(sessions []tracearr.Session) []tracearr.Session {
out := make([]tracearr.Session, 0, len(sessions))
for _, session := range sessions {
// Prerolls are delivery mechanics, not a viewer choice. Treating dozens of
// completed prerolls as taste evidence overwhelms real household history.
if strings.HasPrefix(session.TitleKey(), "preroll") {
continue
}
out = append(out, session)
}
return out
}
func tracearrSeenKey(session tracearr.Session) string {
key := session.TitleKey()
if key != "" && strings.EqualFold(session.MediaType, "movie") &&
session.Year != nil && *session.Year > 0 {
return key + "|" + strconv.Itoa(*session.Year)
}
return key
}
// libraryCandidates reads the pool from the imported library. Returns ok=false when
// there is no library, it is empty, or it errors — every one of which means "ask Emby".
func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) {
+455 -1
View File
@@ -7,11 +7,14 @@ import (
"io"
"log/slog"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
// fakeSource records the queries the engine makes and replays canned answers.
@@ -22,6 +25,8 @@ type fakeSource struct {
similar map[string][]json.RawMessage
itemsErr error
similarErr error
nextUp []json.RawMessage
nextUpErr error
genreQueries []string
similarSeeds []string
@@ -31,6 +36,36 @@ type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
type fakeForYouLibrary struct {
items []json.RawMessage
}
func (f *fakeForYouLibrary) AllRecommendationCandidates(
_ context.Context,
) ([]json.RawMessage, error) {
return f.items, nil
}
func (f *fakeForYouLibrary) LibraryCandidates(
_ context.Context,
_ []string,
_ int,
) ([]json.RawMessage, error) {
return f.items, nil
}
type fakeTracearr struct {
sessions []tracearr.Session
}
func (f fakeTracearr) History(
_ context.Context,
_ string,
_ int,
) ([]tracearr.Session, error) {
return f.sessions, nil
}
func (f *fakeCuratedLibrary) LibraryCandidates(
_ context.Context,
_ []string,
@@ -62,7 +97,11 @@ func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Val
f.genreQueries = append(f.genreQueries, genres)
}
key := params.Get("Filters")
return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil
items := f.itemsByFilter[key]
if limit, err := strconv.Atoi(params.Get("Limit")); err == nil && limit > 0 && len(items) > limit {
items = items[:limit]
}
return &emby.ItemsResult{Items: items}, nil
}
func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
@@ -75,6 +114,69 @@ func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID strin
return &emby.ItemsResult{Items: f.similar[itemID]}, nil
}
func (f *fakeSource) NextUp(
_ context.Context,
_ emby.Credentials,
_ url.Values,
) (*emby.ItemsResult, error) {
if f.nextUpErr != nil {
return nil, f.nextUpErr
}
return &emby.ItemsResult{Items: f.nextUp}, nil
}
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
catalogue := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
})
session := func(show string, season, episode, daysAgo int) tracearr.Session {
value := tracearr.Session{
ID: "session-" + show, MediaType: "episode", ShowTitle: show,
SeasonNumber: intPointer(season), EpisodeNumber: intPointer(episode),
Watched: true, StoppedAt: now.AddDate(0, 0, -daysAgo).Format(time.RFC3339Nano),
}
return value
}
sessions := []tracearr.Session{
session("Early Show", 1, 2, 40),
session("Deep Show", 2, 8, 60),
session("Complete Show", 4, 10, 50),
session("Recent Show", 1, 3, 5),
}
nextUp := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
})
got := abandonedShowCandidates(
newCatalogueIndex(catalogue),
sessions,
nextUp,
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
now,
)
if len(got) != 2 {
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
}
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
}
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
}
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
}
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
}
}
func raw(id, name, itemType string, genres ...string) json.RawMessage {
quoted := make([]string, 0, len(genres))
for _, g := range genres {
@@ -128,6 +230,117 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
}
}
func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsResumable": {},
"IsPlayed": {
json.RawMessage(`{"Id":"seen","Name":"Arrival","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
},
"IsFavorite": {},
},
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
json.RawMessage(`{"Id":"short","Name":"Moon","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000,"MediaStreams":[{"Type":"Video","Codec":"h264"}]}`),
json.RawMessage(`{"Id":"long","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":93000000000,"MediaStreams":[{"Type":"Video","Codec":"hevc"}]}`),
}}
session := tracearr.Session{
MediaType: "movie",
MediaTitle: "Arrival",
Watched: true,
Platform: "Android TV",
SourceVideoCodec: "h264",
VideoDecision: "directplay",
}
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
engine.MinRowItems = 1
rows, err := engine.BuildForYou(
context.Background(),
emby.Credentials{UserID: "u1"},
"Matt",
ForYouOptions{AvailableMinutes: 100},
)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || len(rows[0].Items) != 1 {
t.Fatalf("rows = %+v", rows)
}
if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason"`) ||
!strings.Contains(string(rows[0].Items[0]), `100-minute window`) {
t.Fatalf("explanation missing: %s", rows[0].Items[0])
}
if strings.Contains(string(rows[0].Items[0]), `"Id":"long"`) {
t.Fatalf("over-budget item was retained: %s", rows[0].Items[0])
}
}
func TestPrepareForYouKeepsAnOverProvisionedPoolAndSpecificEvidence(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsResumable": {},
"IsPlayed": {
json.RawMessage(`{"Id":"watched","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"UserData":{"Played":true}}`),
},
"IsFavorite": {},
}}
catalogue := []json.RawMessage{
json.RawMessage(`{"Id":"arrival","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
}
for i := 0; i < 30; i++ {
catalogue = append(catalogue, json.RawMessage(
`{"Id":"candidate-`+strconv.Itoa(i)+`","Name":"Candidate `+strconv.Itoa(i)+
`","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000}`,
))
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: catalogue}
session := tracearr.Session{
ID: "trace-1", ServerID: "server-1", MediaTitle: "Arrival",
MediaType: "movie", Year: intPointer(2016), Watched: true,
StartedAt: "2026-07-20T08:00:00Z",
}
session.User.ID = "trace-user"
session.User.Username = "Matt"
result, err := engine.PrepareForYou(
context.Background(), emby.Credentials{UserID: "emby-user"}, "Matt",
[]tracearr.Session{session},
)
if err != nil {
t.Fatalf("PrepareForYou: %v", err)
}
if len(result.Candidates) < 30 {
t.Fatalf("prepared pool was prematurely row-sized: %d candidates", len(result.Candidates))
}
foundSpecific := false
for _, candidate := range result.Candidates {
if strings.Contains(candidate.RecommendationReason, "Because you finished Arrival") {
foundSpecific = true
break
}
}
if !foundSpecific {
t.Fatal("expected a candidate explanation grounded in the completed Tracearr title")
}
if result.Profile.TracearrUserID != "trace-user" || len(result.Mappings) != 1 {
t.Fatalf("profile/mapping = %+v / %+v", result.Profile, result.Mappings)
}
}
func intPointer(value int) *int { return &value }
func TestStableEvidenceIndexDistributesCandidatesAcrossCompletedTitles(t *testing.T) {
seen := map[int]bool{}
for i := 0; i < 20; i++ {
seen[stableEvidenceIndex("candidate-"+strconv.Itoa(i), 3)] = true
}
if len(seen) != 3 {
t.Fatalf("evidence indices = %+v, want all three sources represented", seen)
}
}
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -179,6 +392,123 @@ func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
}
}
func TestBuildRowsExcludesPlayedTitlesBeyondTheOldHistoryWindow(t *testing.T) {
played := make([]json.RawMessage, 0, 61)
for i := 0; i < 60; i++ {
played = append(played, raw("history-"+strconv.Itoa(i), "History", "Movie", "Drama"))
}
played = append(played, raw("old-watched", "Old Watched", "Movie", "Drama"))
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{"IsPlayed": played},
similar: map[string][]json.RawMessage{},
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
raw("old-watched", "Old Watched", "Movie", "Drama"),
raw("new-pick", "New Pick", "Movie", "Drama"),
}}
engine.MinRowItems = 1
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatalf("BuildRows: %v", err)
}
for _, row := range rows {
for _, candidate := range row.Items {
if strings.Contains(string(candidate), `"Id":"old-watched"`) {
t.Fatalf("row %q retained a title older than the previous 60-item exclusion window", row.ID)
}
}
}
}
func TestBuildForYouExcludesTracearrCompletedTitleMissingFromEmbyHistory(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("taste", "Taste", "Movie", "Science Fiction")},
}}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
json.RawMessage(`{"Id":"watched-copy","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"]}`),
raw("unseen", "Moon", "Movie", "Science Fiction"),
}}
year := 2016
session := tracearr.Session{
MediaType: "movie", MediaTitle: "Arrival", Year: &year, Watched: true,
}
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
engine.MinRowItems = 1
rows, err := engine.BuildForYou(
context.Background(), emby.Credentials{UserID: "u1"}, "FamilyTV", ForYouOptions{},
)
if err != nil {
t.Fatalf("BuildForYou: %v", err)
}
if len(rows) != 1 || len(rows[0].Items) != 1 ||
!strings.Contains(string(rows[0].Items[0]), `"Id":"unseen"`) {
t.Fatalf("Tracearr-completed title was not excluded: %+v", rows)
}
}
func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Drama": 2, "Science Fiction": 1},
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
}
evidence := map[string][]PreparedEvidence{
"drama": {{
ItemID: "arrival", Title: "Arrival",
Genres: []string{"Drama", "Science Fiction"},
}},
}
counts := map[string]int{}
kinds := map[string]int{}
for i := 0; i < 20; i++ {
candidate := item(
"candidate-"+strconv.Itoa(i), "Candidate", "Movie",
[]string{"Drama", "Science Fiction"}, 7,
)
_, _, kind, _, _ := explainPreparedRecommendation(
profile, candidate, compatibilityProfile{}, false, evidence, counts,
)
kinds[kind]++
}
if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
}
if kinds["genre"] == 0 {
t.Fatalf("reason kinds were not mixed: %+v", kinds)
}
}
func TestPreparedExplanationRejectsOneBroadGenreAsSpecificEvidence(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Drama": 2},
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
}
evidence := map[string][]PreparedEvidence{
"drama": {{ItemID: "source", Title: "Source", Genres: []string{"Drama"}}},
}
_, _, kind, _, _ := explainPreparedRecommendation(
profile, item("candidate", "Candidate", "Movie", []string{"Drama"}, 7),
compatibilityProfile{}, false, evidence, map[string]int{},
)
if kind != "genre" {
t.Fatalf("one broad shared genre produced %q, want genre", kind)
}
}
func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
sessions := []tracearr.Session{
{MediaTitle: "PreRoll_Swirls"},
{MediaTitle: "A Real Film"},
}
got := recommendationSessions(sessions)
if len(got) != 1 || got[0].MediaTitle != "A Real Film" {
t.Fatalf("recommendation sessions = %+v", got)
}
}
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -256,6 +586,130 @@ func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
}
}
func TestDefaultCuratedRowsIncludePersonalizedShowGenres(t *testing.T) {
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
got := map[string]string{}
for _, row := range engine.CuratedRows {
got[row.ID] = row.Title
}
for id, title := range map[string]string{
"curated:comedy-shows": "Comedy Shows",
"curated:drama-shows": "Drama Shows",
"curated:horror-shows": "Horror Shows",
} {
if got[id] != title {
t.Fatalf("%s title = %q, want %q", id, got[id], title)
}
}
}
func TestDefaultCuratedRowsIncludeMovieGenresAndStudioFamilies(t *testing.T) {
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
got := map[string]CuratedRow{}
for _, row := range engine.CuratedRows {
got[row.ID] = row
}
for _, id := range []string{
"curated:movies:genre:science-fiction",
"curated:movies:genre:animation",
"curated:movies:studio:pixar",
"curated:movies:studio:disney",
} {
row, ok := got[id]
if !ok {
t.Fatalf("missing default movie shelf %q", id)
}
if row.Kind != "movies" || !row.RequireAffinity {
t.Fatalf("movie shelf %q = %+v", id, row)
}
}
}
func TestMovieShelvesRequireAffinityAndRankAStudioBeforeItsGenre(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {
json.RawMessage(`{"Id":"watched","Name":"Toy Story","Type":"Movie","Genres":["Comedy"],"Studios":[{"Name":"Pixar Animation Studios"}]}`),
},
}}
engine := testEngine(source)
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("comedy-1", "Comedy One", "Movie", "Comedy"),
raw("comedy-2", "Comedy Two", "Movie", "Comedy"),
},
"Drama": {
raw("drama-1", "Drama One", "Movie", "Drama"),
raw("drama-2", "Drama Two", "Movie", "Drama"),
},
"studio:Pixar": {
raw("pixar-1", "Pixar One", "Movie", "Animation"),
raw("pixar-2", "Pixar Two", "Movie", "Animation"),
},
}}
engine.CuratedRows = []CuratedRow{
movieGenreRow("comedy", "Comedy"),
movieGenreRow("drama", "Drama"),
movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
curated := make([]Row, 0, len(rows))
for _, row := range rows {
if strings.HasPrefix(row.ID, "curated:movies:") {
curated = append(curated, row)
}
}
if len(curated) != 2 {
t.Fatalf("movie shelves = %+v, want Pixar and Comedy only", rowTitles(curated))
}
if curated[0].ID != "curated:movies:studio:pixar" ||
curated[1].ID != "curated:movies:genre:comedy" {
t.Fatalf("movie shelf order = %+v", rowTitles(curated))
}
}
func TestCuratedRowsDoNotRepeatCardsAcrossGenres(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("history", "Funny", "Episode", "Comedy")},
}}
engine := testEngine(source)
engine.MinRowItems = 1
engine.RowSize = 3
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
raw("comedy", "Comedy Only", "Series", "Comedy"),
},
"Drama": {
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
raw("drama", "Drama Only", "Series", "Drama"),
},
}}
engine.CuratedRows = []CuratedRow{
{ID: "comedy", Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
{ID: "drama", Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
ids := map[string]int{}
for _, row := range rows {
for _, item := range Decode(row.Items) {
ids[item.ID]++
}
}
if ids["shared"] != 1 {
t.Fatalf("shared card appeared %d times across curated rows", ids["shared"])
}
}
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
+85 -12
View File
@@ -10,7 +10,9 @@ import (
"encoding/json"
"math"
"sort"
"strconv"
"strings"
"unicode"
)
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
@@ -26,14 +28,23 @@ const favoriteWeight = 0.6
// Item is the slice of an Emby item this package reasons about. The raw payload rides
// along so rows can be emitted without re-fetching or re-encoding.
type Item struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
Studios []struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
ProductionYear int `json:"ProductionYear"`
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
RunTimeTicks int64 `json:"RunTimeTicks"`
IndexNumber int `json:"IndexNumber"`
ParentIndexNumber int `json:"ParentIndexNumber"`
Container string `json:"Container"`
MediaStreams []struct {
Type string `json:"Type"`
Codec string `json:"Codec"`
} `json:"MediaStreams"`
Studios []struct {
Name string `json:"Name"`
} `json:"Studios"`
UserData struct {
@@ -46,6 +57,38 @@ type Item struct {
Raw json.RawMessage `json:"-"`
}
func (i Item) RuntimeMinutes() int {
if i.RunTimeTicks <= 0 {
return 0
}
return int(i.RunTimeTicks / 600_000_000)
}
func (i Item) TitleKey() string {
value := i.Name
if i.Type == "Episode" && strings.TrimSpace(i.SeriesName) != "" {
value = i.SeriesName
}
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// SeenKey is a title-level fallback for imported catalogue records, whose payloads
// deliberately contain no per-user UserData. Movies include their year so watching an
// older film does not hide a remake with the same name; episodes collapse to series.
func (i Item) SeenKey() string {
key := i.TitleKey()
if key != "" && strings.EqualFold(i.Type, "Movie") && i.ProductionYear > 0 {
return key + "|" + strconv.Itoa(i.ProductionYear)
}
return key
}
// Seed is a title recent enough to anchor a "Because you watched …" row.
type Seed struct {
ID string
@@ -58,8 +101,9 @@ type Profile struct {
StudioWeights map[string]float64
// Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through.
Seen map[string]bool
Seeds []Seed
Seen map[string]bool
SeenTitles map[string]bool
Seeds []Seed
}
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
@@ -87,12 +131,25 @@ func BuildProfile(history, favorites []Item) Profile {
GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{},
Seen: map[string]bool{},
SeenTitles: map[string]bool{},
}
seedSeen := map[string]bool{}
tasteSeen := map[string]bool{}
for i, item := range history {
weight := math.Pow(recencyDecay, float64(i))
profile.absorb(item, weight)
profile.markSeen(item)
// Several episodes of one series are evidence for one taste, not several
// independent tastes. Keep the newest occurrence's recency weight and still
// mark every item/series identifier as seen.
tasteID := item.ID
if item.SeriesID != "" {
tasteID = item.SeriesID
}
if !tasteSeen[tasteID] {
tasteSeen[tasteID] = true
profile.absorbTaste(item, math.Pow(recencyDecay, float64(i)))
}
// An episode seeds its series, not itself: "Because you watched Severance"
// reads better than "Because you watched Good News".
@@ -113,12 +170,25 @@ func BuildProfile(history, favorites []Item) Profile {
}
func (p *Profile) absorb(item Item, weight float64) {
p.markSeen(item)
p.absorbTaste(item, weight)
}
func (p *Profile) markSeen(item Item) {
if item.ID != "" {
p.Seen[item.ID] = true
}
if item.SeriesID != "" {
p.Seen[item.SeriesID] = true
}
if key := item.SeenKey(); key != "" {
p.SeenTitles[key] = true
}
}
// absorbTaste learns affinity without marking the item watched. This is used for
// browsing signals: lingering on a card is meaningful, but must not hide that card.
func (p *Profile) absorbTaste(item Item, weight float64) {
for _, genre := range item.Genres {
if g := strings.TrimSpace(genre); g != "" {
p.GenreWeights[g] += weight
@@ -168,6 +238,9 @@ func (p Profile) Score(candidate Item) float64 {
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
return -1
}
if p.SeenTitles[candidate.SeenKey()] {
return -1
}
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
return -1
}
+17
View File
@@ -59,6 +59,23 @@ func TestBuildProfileDeduplicatesSeeds(t *testing.T) {
}
}
func TestBuildProfileDoesNotCountEveryEpisodeAsAnotherTasteVote(t *testing.T) {
history := []Item{
episode("ep2", "Second", "series", "Series", []string{"Drama"}),
episode("ep1", "First", "series", "Series", []string{"Drama"}),
item("movie", "Movie", "Movie", []string{"Comedy"}, 0),
}
profile := BuildProfile(history, nil)
if profile.GenreWeights["Drama"] != 1 {
t.Fatalf("repeated series weight = %v, want the newest occurrence only",
profile.GenreWeights["Drama"])
}
if !profile.Seen["ep1"] || !profile.Seen["ep2"] || !profile.Seen["series"] {
t.Fatalf("episode/series exclusions were lost: %+v", profile.Seen)
}
}
func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})
+39
View File
@@ -2,12 +2,51 @@ package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// BrowsingCandidates returns library items the user actively focused or selected,
// strongest first. Impressions are intentionally excluded: merely scrolling past a row
// is not evidence of taste.
func (s *Store) BrowsingCandidates(
ctx context.Context,
userID string,
since time.Time,
limit int,
) ([]json.RawMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT li.payload
FROM row_events re
JOIN library_items li ON li.id = re.item_id
WHERE re.emby_user_id = $1
AND re.occurred_at >= $2
AND re.event IN ('focus', 'select')
GROUP BY li.id, li.payload
ORDER BY
count(*) FILTER (WHERE re.event = 'select') * 20 +
count(*) FILTER (WHERE re.event = 'focus') * 2 +
coalesce(sum(re.dwell_ms), 0) / 10000 DESC,
max(re.occurred_at) DESC
LIMIT $3`, userID, since, limit)
if err != nil {
return nil, fmt.Errorf("store: browsing candidates: %w", err)
}
defer rows.Close()
out := []json.RawMessage{}
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
return nil, err
}
out = append(out, json.RawMessage(payload))
}
return out, rows.Err()
}
// RowEvent is one reported interaction with a home-screen row.
type RowEvent struct {
OccurredAt time.Time
+15
View File
@@ -138,6 +138,21 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
return collectPayloads(rows)
}
// AllRecommendationCandidates returns the complete Movie/Series catalogue for an
// offline For You rebuild. The resulting per-user pool is deliberately over-provisioned
// so a short runtime filter still has enough ranked titles to fill the TV row.
func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE type IN ('Movie', 'Series')
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST`)
if err != nil {
return nil, fmt.Errorf("store: all recommendation candidates: %w", err)
}
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(
+117
View File
@@ -11,11 +11,15 @@ CREATE TABLE IF NOT EXISTS sessions (
server_id TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT 'Memby TV',
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
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';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
@@ -102,3 +106,116 @@ CREATE TABLE IF NOT EXISTS row_events (
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
-- Search terms are retained separately from row engagement so they can inform future
-- ranking/recommendation work without coupling that analysis to rendered rows.
CREATE TABLE IF NOT EXISTS search_history (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
query TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS search_history_user_time_idx
ON search_history (emby_user_id, occurred_at DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
CREATE TABLE IF NOT EXISTS tracearr_sessions (
server_id TEXT NOT NULL DEFAULT '',
tracearr_session_id TEXT NOT NULL,
tracearr_user_id TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
media_type TEXT NOT NULL DEFAULT '',
media_title TEXT NOT NULL DEFAULT '',
show_title TEXT NOT NULL DEFAULT '',
season_number INT,
episode_number INT,
production_year INT,
started_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
duration_ms BIGINT NOT NULL DEFAULT 0,
progress_ms BIGINT NOT NULL DEFAULT 0,
total_duration_ms BIGINT NOT NULL DEFAULT 0,
watched BOOLEAN NOT NULL DEFAULT false,
device TEXT NOT NULL DEFAULT '',
player TEXT NOT NULL DEFAULT '',
product TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
is_transcode BOOLEAN NOT NULL DEFAULT false,
video_decision TEXT NOT NULL DEFAULT '',
audio_decision TEXT NOT NULL DEFAULT '',
source_video_codec TEXT NOT NULL DEFAULT '',
source_audio_codec TEXT NOT NULL DEFAULT '',
emby_item_id TEXT NOT NULL DEFAULT '',
emby_series_id TEXT NOT NULL DEFAULT '',
source_fingerprint BYTEA NOT NULL,
source_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (server_id, tracearr_session_id)
);
CREATE INDEX IF NOT EXISTS tracearr_sessions_user_time_idx
ON tracearr_sessions (tracearr_user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_username_time_idx
ON tracearr_sessions (lower(username), started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_item_idx
ON tracearr_sessions (emby_item_id) WHERE emby_item_id <> '';
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_series_idx
ON tracearr_sessions (emby_series_id) WHERE emby_series_id <> '';
-- One compact derived profile per Emby user. Variable affinity maps stay together as
-- JSON because the builder reads and replaces the whole profile; no request filters
-- inside these maps.
CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
emby_user_id TEXT PRIMARY KEY,
tracearr_user_id TEXT NOT NULL DEFAULT '',
tracearr_username TEXT NOT NULL DEFAULT '',
source_session_count INT NOT NULL DEFAULT 0,
mean_completion_ratio REAL NOT NULL DEFAULT 0,
typical_session_minutes INT NOT NULL DEFAULT 0,
genre_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
title_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
studio_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
signals_through TIMESTAMPTZ,
built_at TIMESTAMPTZ,
pool_built_at TIMESTAMPTZ,
dirty_since TIMESTAMPTZ DEFAULT now(),
last_error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx
ON recommendation_user_profiles (dirty_since)
WHERE dirty_since IS NOT NULL;
-- Every eligible ranked title is retained. At household scale this is only tens of
-- thousands of compact rows and gives short runtime filters far more headroom than the
-- old 240-title request pool.
CREATE TABLE IF NOT EXISTS for_you_candidates (
emby_user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
base_rank INT NOT NULL,
base_score REAL NOT NULL DEFAULT 0,
runtime_minutes INT NOT NULL DEFAULT 0,
affinity_score REAL NOT NULL DEFAULT 0,
compatibility_score REAL NOT NULL DEFAULT 0,
compatibility_label TEXT NOT NULL DEFAULT '',
reason_kind TEXT NOT NULL DEFAULT '',
reason_genre TEXT NOT NULL DEFAULT '',
reason_source_session_id TEXT NOT NULL DEFAULT '',
reason_source_item_id TEXT NOT NULL DEFAULT '',
reason_source_title TEXT NOT NULL DEFAULT '',
recommendation_reason TEXT NOT NULL DEFAULT '',
built_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, item_id),
FOREIGN KEY (item_id) REFERENCES library_items(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS for_you_candidates_user_rank_idx
ON for_you_candidates (emby_user_id, base_rank);
CREATE INDEX IF NOT EXISTS for_you_candidates_user_runtime_rank_idx
ON for_you_candidates (emby_user_id, runtime_minutes, base_rank);
+4 -2
View File
@@ -104,10 +104,12 @@ func (s *Store) SetUpdatePolicy(ctx context.Context, policy appupdate.Policy) er
func (s *Store) NewestSession(ctx context.Context) (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, client_version, client_protocol, last_seen_at
FROM sessions ORDER BY last_seen_at DESC LIMIT 1`).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
+94 -16
View File
@@ -20,14 +20,16 @@ var ErrNotFound = errors.New("store: session not found")
var ErrDeviceLimit = errors.New("store: device limit reached")
type Session struct {
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
LastSeenAt time.Time
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
ClientVersion string
ClientProtocol string
LastSeenAt time.Time
}
type Store struct {
@@ -50,6 +52,58 @@ func (s *Store) Close() { s.pool.Close() }
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// RecordSearch stores a normalized query for future per-user ranking analysis.
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
_, err := s.pool.Exec(ctx,
`WITH inserted AS (
INSERT INTO search_history (emby_user_id, query) VALUES ($1, $2)
RETURNING id
)
DELETE FROM search_history
WHERE emby_user_id = $1
AND occurred_at < now() - interval '30 days'`,
userID, query)
return err
}
// RecentSearches returns a user's distinct queries in most-recently-used order.
// Case-only duplicates collapse to the spelling used most recently.
func (s *Store) RecentSearches(
ctx context.Context,
userID string,
since time.Time,
limit int,
) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT query
FROM (
SELECT DISTINCT ON (lower(query)) query, occurred_at
FROM search_history
WHERE emby_user_id = $1 AND occurred_at >= $2
ORDER BY lower(query), occurred_at DESC
) AS latest
ORDER BY occurred_at DESC
LIMIT $3`,
userID, since, limit)
if err != nil {
return nil, fmt.Errorf("store: recent searches: %w", err)
}
defer rows.Close()
queries := make([]string, 0, limit)
for rows.Next() {
var query string
if err := rows.Scan(&query); err != nil {
return nil, fmt.Errorf("store: scan recent search: %w", err)
}
queries = append(queries, query)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: read recent searches: %w", err)
}
return queries, nil
}
// Migrate applies the schema. It is idempotent, so it runs on every boot.
func (s *Store) Migrate(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, schema); err != nil {
@@ -94,18 +148,21 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
_, err = tx.Exec(ctx, `
INSERT INTO sessions (
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name,
client_version, client_protocol
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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_name = EXCLUDED.device_name,
client_version = EXCLUDED.client_version,
client_protocol = EXCLUDED.client_protocol,
last_seen_at = now()`,
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
sess.ServerID, sess.DeviceID, sess.DeviceName)
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol)
if err != nil {
return nil, 0, fmt.Errorf("store: create session: %w", err)
}
@@ -121,10 +178,12 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
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, device_name, last_seen_at
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
FROM sessions WHERE token_hash = $1`, hash).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt)
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
@@ -141,6 +200,23 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error {
return err
}
// UpdateSessionClientIdentity remembers the last non-empty identity supplied by a TV.
// Headerless image requests can then still be attributed to the correct app build.
func (s *Store) UpdateSessionClientIdentity(
ctx context.Context,
hash []byte,
version, protocol string,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE sessions
SET client_version = CASE WHEN $2 <> '' THEN $2 ELSE client_version END,
client_protocol = CASE WHEN $3 <> '' THEN $3 ELSE client_protocol END,
last_seen_at = now()
WHERE token_hash = $1`,
hash, version, protocol)
return err
}
func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash)
return err
@@ -176,10 +252,11 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
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
current.device_name, current.client_version, current.client_protocol,
current.last_seen_at
)
SELECT token_hash, emby_user_id, emby_token, username, server_id,
device_id, device_name, last_seen_at
device_id, device_name, client_version, client_protocol, last_seen_at
FROM retired`,
maxClients,
)
@@ -193,7 +270,8 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
}