Big changes
This commit is contained in:
+24
-11
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 it’ll 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 |
@@ -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
|
||||
|
||||
@@ -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 couldn’t 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 & TV backdrops</string>
|
||||
<string name="developer_name">ponzischeme89</string>
|
||||
</resources>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
}[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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) + `"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
-1
@@ -87,7 +87,7 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
|
||||
// Recommendations cost several Emby queries to build, so they must survive the cache
|
||||
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
|
||||
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
|
||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows: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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user