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
|
# 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.
|
# 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.
|
# Postgres password for the memby role. Generate one, e.g.
|
||||||
# openssl rand -base64 24
|
# openssl rand -base64 24
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=7dfc3eb07108013c7bea9787457397551b5eb9059adac51aea4741f4ec649be2
|
||||||
|
|
||||||
# Host port the gateway listens on.
|
# Fixed NAS host port used by the mserver.sublogue.com reverse proxy.
|
||||||
MEMBY_PORT=8080
|
MEMBY_PORT=32768
|
||||||
|
|
||||||
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
|
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
|
||||||
MEMBY_LOG_LEVEL=INFO
|
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.
|
# Maximum number of distinct Memby TVs one Emby user may keep signed in.
|
||||||
MEMBY_MAX_CLIENTS_PER_USER=1
|
MEMBY_MAX_CLIENTS_PER_USER=1
|
||||||
|
|
||||||
# Admin interface at http://<host>:8080/admin/ — library imports, the maintenance
|
# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the
|
||||||
# switch, and row analytics. Leave blank to disable /admin entirely. Generate with
|
# maintenance switch, and row analytics. Leave blank to disable /admin entirely. Generate with
|
||||||
# openssl rand -hex 32
|
# 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.
|
# Public gateway address and a dedicated token used only by the Gitea release workflow.
|
||||||
# Generate the token with: openssl rand -hex 32
|
# 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
|
# 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.
|
# recently active TV session, which works but stops if that user is removed.
|
||||||
#MEMBY_SYNC_USER_ID=
|
MEMBY_SYNC_USER_ID=23bf90fc1fe049ce898db7adda1ed7bf
|
||||||
#MEMBY_SYNC_API_KEY=
|
MEMBY_SYNC_API_KEY=56775917938841e7ac1b6a233d4d5075
|
||||||
|
|
||||||
# Optional Sonarr calendar integration. Use the URL reachable from this container,
|
# 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
|
# not necessarily the address entered in a browser. The API key is in Sonarr under
|
||||||
# Settings > General > Security.
|
# Settings > General > Security.
|
||||||
#MEMBY_SONARR_URL=http://192.168.20.2:8989
|
MEMBY_SONARR_URL=http://10.0.0.2:8989
|
||||||
#MEMBY_SONARR_API_KEY=
|
MEMBY_SONARR_API_KEY=6b608b051ee24582925773bd5dfbe37a
|
||||||
MEMBY_SONARR_TTL=5m
|
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)
|
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.
|
`local.properties` must contain `sdk.dir=...` when building from the CLI.
|
||||||
|
|
||||||
Lint has `abortOnError = false` (media3's `@UnstableApi` opt-in check would otherwise fail
|
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
|
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.
|
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`
|
`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
|
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`.
|
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.
|
gateway proxy, `api_key=` for Emby.
|
||||||
- Video always direct-plays from Emby. The gateway returns a URL; it never proxies a
|
- Video always direct-plays from Emby. The gateway returns a URL; it never proxies a
|
||||||
stream. Don't route playback through it.
|
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`
|
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
|
(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
|
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.
|
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
|
**Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events
|
||||||
with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s,
|
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
|
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
|
`media3-exoplayer-hls` dependency is already present for when that's added. Progress is
|
||||||
reported back to Emby via `reportPlaybackStarted/Progress/Stopped`.
|
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
|
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
|
||||||
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
|
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
|
||||||
targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are
|
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
|
three large files — new screens generally belong in `ui/<feature>/` rather than growing
|
||||||
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
|
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
|
||||||
everything must be reachable by D-pad only.
|
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
|
- **In-app playback** with Media3/ExoPlayer (movies play directly; a series plays its
|
||||||
next-up / first episode).
|
next-up / first episode).
|
||||||
- **Favorites** managed through the Emby API and shown on the home screen.
|
- **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
|
## 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
|
history" — and has an admin page at `/admin/` for imports, an offline switch, and
|
||||||
per-row engagement.
|
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
|
## Server address
|
||||||
|
|
||||||
Memby is built for one Emby server, so the address is baked into the APK instead of being
|
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
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.1.54"
|
val defaultVersionName = "0.1.69"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
@@ -116,6 +116,21 @@ android {
|
|||||||
abortOnError = false
|
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 {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
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-runtime-ktx:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-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")
|
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
|
||||||
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
|
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
|
||||||
implementation("androidx.metrics:metrics-performance:1.0.0")
|
implementation("androidx.metrics:metrics-performance:1.0.0")
|
||||||
@@ -167,4 +186,15 @@ dependencies {
|
|||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
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). -->
|
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<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. -->
|
<!-- This is a TV app: no touchscreen, uses the Leanback launcher. -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
@@ -19,18 +43,22 @@
|
|||||||
android:name=".MembyApp"
|
android:name=".MembyApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:banner="@drawable/app_banner"
|
android:banner="@drawable/app_banner"
|
||||||
android:icon="@drawable/app_banner"
|
android:icon="@drawable/emby_logo"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@drawable/emby_logo"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Memby">
|
android:theme="@style/Theme.Memby">
|
||||||
<profileable android:shell="true" tools:targetApi="q" />
|
<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
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
|
tools:ignore="DiscouragedApi"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
|
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -43,7 +71,8 @@
|
|||||||
android:name=".ui.screensaver.ScreensaverActivity"
|
android:name=".ui.screensaver.ScreensaverActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="landscape"
|
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
|
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
|
||||||
the TV is never left displaying the old, black Dream surface. -->
|
the TV is never left displaying the old, black Dream surface. -->
|
||||||
@@ -62,14 +91,15 @@
|
|||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
|
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).
|
<!-- The system screensaver (Daydream / Ambient mode source).
|
||||||
Interactive: select to open the panel, play, or favourite. -->
|
Interactive: select to open the panel, play, or favourite. -->
|
||||||
<service
|
<service
|
||||||
android:name=".screensaver.MembyDreamService"
|
android:name=".screensaver.MembyDreamService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:icon="@drawable/app_banner"
|
android:icon="@drawable/emby_logo"
|
||||||
android:label="@string/screensaver_name"
|
android:label="@string/screensaver_name"
|
||||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ponzischeme89.memby
|
package com.ponzischeme89.memby
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.ponzischeme89.memby.data.EmbyRepository
|
import com.ponzischeme89.memby.data.EmbyRepository
|
||||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||||
@@ -11,6 +12,9 @@ import com.ponzischeme89.memby.data.SettingsStore
|
|||||||
* settings instance without pulling in a DI framework.
|
* settings instance without pulling in a DI framework.
|
||||||
*/
|
*/
|
||||||
object ServiceLocator {
|
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
|
lateinit var settings: SettingsStore
|
||||||
private set
|
private set
|
||||||
lateinit var repository: EmbyRepository
|
lateinit var repository: EmbyRepository
|
||||||
@@ -22,6 +26,6 @@ object ServiceLocator {
|
|||||||
if (::repository.isInitialized) return
|
if (::repository.isInitialized) return
|
||||||
settings = SettingsStore(context.applicationContext)
|
settings = SettingsStore(context.applicationContext)
|
||||||
repository = EmbyRepository(settings)
|
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.GatewayAuthPolicy
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
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.GatewayRowEvent
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
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.EmbyApi
|
||||||
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
||||||
import com.ponzischeme89.memby.data.remote.GatewayApi
|
import com.ponzischeme89.memby.data.remote.GatewayApi
|
||||||
@@ -49,6 +51,24 @@ data class HomeSnapshot(
|
|||||||
val partial: Boolean = false,
|
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. */
|
/** A resolved, directly playable stream. */
|
||||||
data class Playable(
|
data class Playable(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
@@ -56,6 +76,10 @@ data class Playable(
|
|||||||
val url: String,
|
val url: String,
|
||||||
val resumePositionMs: Long = 0L,
|
val resumePositionMs: Long = 0L,
|
||||||
val logoUrl: String? = null,
|
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) {
|
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 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) -----
|
// --- API instance caching (rebuilt only when the server URL changes) -----
|
||||||
|
|
||||||
private var cachedApi: EmbyApi? = null
|
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 —
|
* Signs in. [serverUrl] is only consulted when the build does not hardwire one —
|
||||||
* with a hardwired address the setup screen never collects it.
|
* 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()
|
clearPlayableCache()
|
||||||
settings.ensureDeviceId()
|
settings.ensureDeviceId()
|
||||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
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()) {
|
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
|
||||||
"Gateway did not return a session"
|
"Gateway did not return a session"
|
||||||
}
|
}
|
||||||
|
val authenticatedUsername = result.username.ifBlank { username }
|
||||||
settings.saveSession(
|
settings.saveSession(
|
||||||
gateway,
|
gateway,
|
||||||
result.token,
|
result.token,
|
||||||
result.userId,
|
result.userId,
|
||||||
result.username.ifBlank { username },
|
authenticatedUsername,
|
||||||
result.serverId.takeIf { it.isNotBlank() },
|
result.serverId.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
settings.setDeviceName(deviceName)
|
settings.setDeviceName(deviceName)
|
||||||
observedSettings = settings.snapshot()
|
observedSettings = settings.snapshot()
|
||||||
return
|
return authenticatedUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
val base = resolveServerUrl(ServerConfig.hardwiredUrl, serverUrl)
|
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.saveSession(base, token, userId, username, result.serverId)
|
||||||
settings.setDeviceName(deviceName)
|
settings.setDeviceName(deviceName)
|
||||||
observedSettings = settings.snapshot()
|
observedSettings = settings.snapshot()
|
||||||
|
return username
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun authPolicy(): GatewayAuthPolicy? =
|
suspend fun authPolicy(): GatewayAuthPolicy? =
|
||||||
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
|
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() {
|
suspend fun signOut() {
|
||||||
// Retire the gateway token server-side too, so a lost TV can't keep reading the
|
// 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.
|
// 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. */
|
/** Unfinished movies and episodes for the current Emby user. */
|
||||||
suspend fun getContinueWatching(limit: Int = 24): List<BaseItem> {
|
suspend fun getContinueWatching(limit: Int = 24): List<BaseItem> {
|
||||||
if (ServerConfig.isGateway) return getHome(limit).continueWatching
|
if (ServerConfig.isGateway) return getHome(limit).continueWatching
|
||||||
return getHomeItems(
|
val userId = snapshot.userId ?: error("Not connected")
|
||||||
params = mapOf(
|
return requireApi().getResumeItems(
|
||||||
"Filters" to "IsResumable",
|
userId,
|
||||||
"IncludeItemTypes" to "Movie,Episode",
|
mapOf(
|
||||||
"Recursive" to "true",
|
"Recursive" to "true",
|
||||||
"SortBy" to "DatePlayed",
|
"MediaTypes" to "Video",
|
||||||
"SortOrder" to "Descending",
|
|
||||||
"Limit" to limit.toString(),
|
"Limit" to limit.toString(),
|
||||||
|
"Fields" to "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
|
||||||
|
"ImageTypeLimit" to "1",
|
||||||
|
"EnableImageTypes" to "Backdrop,Primary,Logo",
|
||||||
|
"EnableUserData" to "true",
|
||||||
),
|
),
|
||||||
fields = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
|
).items
|
||||||
imageTypes = "Backdrop,Primary,Logo",
|
|
||||||
includeUserData = true,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */
|
/** 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. */
|
/** 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
|
* 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
|
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
|
* 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
|
* 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(
|
return requireApi().getItem(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
itemId = itemId,
|
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. */
|
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
|
||||||
private suspend fun getHomeItems(
|
private suspend fun getHomeItems(
|
||||||
params: Map<String, String>,
|
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. */
|
/** Backdrop rotation interval, clamped to a sane range. */
|
||||||
fun rotationIntervalMillis(): Long =
|
fun rotationIntervalMillis(): Long =
|
||||||
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
|
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
|
||||||
@@ -498,6 +629,97 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
resolvePlayable(item)
|
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> {
|
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
|
||||||
val request = scope.async(start = CoroutineStart.LAZY) {
|
val request = scope.async(start = CoroutineStart.LAZY) {
|
||||||
try {
|
try {
|
||||||
@@ -539,6 +761,10 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
url = playback.url,
|
url = playback.url,
|
||||||
resumePositionMs = playback.resumePositionMs,
|
resumePositionMs = playback.resumePositionMs,
|
||||||
logoUrl = logoUrl(item),
|
logoUrl = logoUrl(item),
|
||||||
|
subtitles = playback.subtitles,
|
||||||
|
mediaSourceId = playback.mediaSourceId,
|
||||||
|
playSessionId = playback.playSessionId,
|
||||||
|
playMethod = playback.playMethod,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (item.isSeries) {
|
if (item.isSeries) {
|
||||||
@@ -549,20 +775,30 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
append(item.name)
|
append(item.name)
|
||||||
episode.name.takeIf { it.isNotBlank() }?.let { append(" – $it") }
|
episode.name.takeIf { it.isNotBlank() }?.let { append(" – $it") }
|
||||||
}
|
}
|
||||||
|
val discovery = directPlayback(episode.id, episode.resumePositionMs)
|
||||||
return Playable(
|
return Playable(
|
||||||
episode.id,
|
episode.id,
|
||||||
title,
|
title,
|
||||||
buildStreamUrl(episode.id),
|
buildStreamUrl(episode.id),
|
||||||
episode.resumePositionMs,
|
episode.resumePositionMs,
|
||||||
logoUrl(item),
|
logoUrl(item),
|
||||||
|
discovery.subtitles,
|
||||||
|
discovery.mediaSourceId,
|
||||||
|
discovery.playSessionId,
|
||||||
|
discovery.playMethod,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val discovery = directPlayback(item.id, item.resumePositionMs)
|
||||||
return Playable(
|
return Playable(
|
||||||
item.id,
|
item.id,
|
||||||
item.name,
|
item.name,
|
||||||
buildStreamUrl(item.id),
|
buildStreamUrl(item.id),
|
||||||
item.resumePositionMs,
|
item.resumePositionMs,
|
||||||
logoUrl(item),
|
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) {
|
if (ServerConfig.isGateway) {
|
||||||
requireGateway().report("started", GatewayPlaybackReport(itemId, positionMs))
|
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
|
||||||
return
|
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) {
|
if (ServerConfig.isGateway) {
|
||||||
requireGateway().report("progress", GatewayPlaybackReport(itemId, positionMs, isPaused))
|
requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName))
|
||||||
return
|
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 {
|
try {
|
||||||
if (ServerConfig.isGateway) {
|
if (ServerConfig.isGateway) {
|
||||||
// Stopping is also what drops the gateway's cached rows for this user,
|
// 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.
|
// 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 {
|
} else {
|
||||||
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
|
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
_playbackStops.tryEmit(itemId)
|
_playbackStops.tryEmit(session.itemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enqueuePlaybackStopped(itemId: String, positionMs: Long) {
|
fun enqueuePlaybackStopped(session: PlaybackSession, positionMs: Long) {
|
||||||
scope.launch {
|
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? =
|
private suspend fun firstNextUpEpisode(userId: String, seriesId: String): BaseItem? =
|
||||||
runCatching {
|
runCatching {
|
||||||
requireApi().getNextUp(
|
requireApi().getNextUp(
|
||||||
@@ -671,6 +1024,20 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
return imageUrl(item.id, "Primary", tag, maxWidth)
|
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.
|
* Builds an artwork URL for whichever backend this build uses.
|
||||||
*
|
*
|
||||||
@@ -725,16 +1092,51 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
"&DeviceId=${encode(deviceId)}"
|
"&DeviceId=${encode(deviceId)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playbackReport(itemId: String, positionMs: Long, isPaused: Boolean) =
|
private fun playbackReport(
|
||||||
|
session: PlaybackSession,
|
||||||
|
positionMs: Long,
|
||||||
|
isPaused: Boolean,
|
||||||
|
eventName: String?,
|
||||||
|
) =
|
||||||
PlaybackReport(
|
PlaybackReport(
|
||||||
itemId = itemId,
|
itemId = session.itemId,
|
||||||
|
mediaSourceId = session.mediaSourceId,
|
||||||
|
playSessionId = session.playSessionId,
|
||||||
positionTicks = millisecondsToTicks(positionMs),
|
positionTicks = millisecondsToTicks(positionMs),
|
||||||
isPaused = isPaused,
|
isPaused = isPaused,
|
||||||
|
playMethod = session.playMethod,
|
||||||
|
eventName = eventName,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
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(
|
private data class CachedPlayable(
|
||||||
val playable: Playable,
|
val playable: Playable,
|
||||||
val expiresAtMs: Long,
|
val expiresAtMs: Long,
|
||||||
@@ -754,11 +1156,13 @@ class DeviceLimitException(
|
|||||||
val maxClients: Int,
|
val maxClients: Int,
|
||||||
) : Exception("Memby device allowance reached")
|
) : 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? {
|
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
|
||||||
if (body.isBlank()) return null
|
if (body.isBlank()) return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val parsed = Json { ignoreUnknownKeys = true }
|
val parsed = errorBodyJson.decodeFromString<GatewayAuthError>(body)
|
||||||
.decodeFromString<GatewayAuthError>(body)
|
|
||||||
parsed.takeIf {
|
parsed.takeIf {
|
||||||
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
|
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
|
||||||
}?.let {
|
}?.let {
|
||||||
@@ -802,8 +1206,7 @@ private fun maintenanceMessage(t: HttpException): String? = runCatching {
|
|||||||
internal fun parseMaintenanceMessage(body: String): String? {
|
internal fun parseMaintenanceMessage(body: String): String? {
|
||||||
if (body.isBlank()) return null
|
if (body.isBlank()) return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val parsed = Json { ignoreUnknownKeys = true }
|
val parsed = errorBodyJson.decodeFromString<MaintenanceResponse>(body)
|
||||||
.decodeFromString<MaintenanceResponse>(body)
|
|
||||||
parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160)
|
parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -811,6 +1214,9 @@ internal fun parseMaintenanceMessage(body: String): String? {
|
|||||||
/** True when this failure is the gateway reporting a deliberate outage. */
|
/** True when this failure is the gateway reporting a deliberate outage. */
|
||||||
fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503
|
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
|
@Serializable
|
||||||
private data class MaintenanceResponse(
|
private data class MaintenanceResponse(
|
||||||
val maintenance: Boolean = false,
|
val maintenance: Boolean = false,
|
||||||
|
|||||||
@@ -44,12 +44,18 @@ data class Settings(
|
|||||||
val updateToken: String? = null,
|
val updateToken: String? = null,
|
||||||
// Show each item's Emby "Logo" image in place of the plain-text title.
|
// Show each item's Emby "Logo" image in place of the plain-text title.
|
||||||
val showTitleLogo: Boolean = true,
|
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.
|
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
|
||||||
val ringColorHex: String = DEFAULT_RING_COLOR,
|
val ringColorHex: String = DEFAULT_RING_COLOR,
|
||||||
val lastBackdropUrl: String? = null,
|
val lastBackdropUrl: String? = null,
|
||||||
/** Comma-separated, user-controlled order of rows shown on the client home. */
|
/** Comma-separated, user-controlled order of rows shown on the client home. */
|
||||||
val homeSections: String = DEFAULT_HOME_SECTIONS,
|
val homeSections: String = DEFAULT_HOME_SECTIONS,
|
||||||
val homeCacheJson: String? = null,
|
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 homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
|
||||||
val showHomeCardMetadata: Boolean = true,
|
val showHomeCardMetadata: Boolean = true,
|
||||||
val profiles: List<EmbyProfile> = emptyList(),
|
val profiles: List<EmbyProfile> = emptyList(),
|
||||||
@@ -80,6 +86,8 @@ data class EmbyProfile(
|
|||||||
val username: String,
|
val username: String,
|
||||||
val serverId: String? = null,
|
val serverId: String? = null,
|
||||||
val homeCacheJson: String? = null,
|
val homeCacheJson: String? = null,
|
||||||
|
val forYouMinutes: Int = 0,
|
||||||
|
val hasOpenedForYou: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsStore(private val context: Context) {
|
class SettingsStore(private val context: Context) {
|
||||||
@@ -91,6 +99,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
get() = latestSettings
|
get() = latestSettings
|
||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
|
const val MAX_SEEN_ALERTS = 40
|
||||||
val SERVER_URL = stringPreferencesKey("server_url")
|
val SERVER_URL = stringPreferencesKey("server_url")
|
||||||
val TOKEN = stringPreferencesKey("token")
|
val TOKEN = stringPreferencesKey("token")
|
||||||
val USER_ID = stringPreferencesKey("user_id")
|
val USER_ID = stringPreferencesKey("user_id")
|
||||||
@@ -103,13 +112,17 @@ class SettingsStore(private val context: Context) {
|
|||||||
val UPDATE_REPO = stringPreferencesKey("update_repo")
|
val UPDATE_REPO = stringPreferencesKey("update_repo")
|
||||||
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
||||||
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
||||||
|
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
|
||||||
val RING_COLOR = stringPreferencesKey("ring_color")
|
val RING_COLOR = stringPreferencesKey("ring_color")
|
||||||
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
||||||
val HOME_SECTIONS = stringPreferencesKey("home_sections")
|
val HOME_SECTIONS = stringPreferencesKey("home_sections")
|
||||||
val HOME_CACHE = stringPreferencesKey("home_cache")
|
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 HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
|
||||||
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
|
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
|
||||||
val PROFILES = stringPreferencesKey("profiles")
|
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) {
|
suspend fun setShowTitleLogo(enabled: Boolean) {
|
||||||
context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled }
|
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) {
|
suspend fun setRingColor(hex: String) {
|
||||||
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
|
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 {
|
fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let {
|
||||||
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
|
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -227,6 +300,8 @@ class SettingsStore(private val context: Context) {
|
|||||||
username = username,
|
username = username,
|
||||||
serverId = serverId,
|
serverId = serverId,
|
||||||
homeCacheJson = previous?.homeCacheJson,
|
homeCacheJson = previous?.homeCacheJson,
|
||||||
|
forYouMinutes = previous?.forYouMinutes ?: 0,
|
||||||
|
hasOpenedForYou = previous?.hasOpenedForYou ?: false,
|
||||||
)
|
)
|
||||||
profiles.removeAll { it.id == id }
|
profiles.removeAll { it.id == id }
|
||||||
profiles.add(profile)
|
profiles.add(profile)
|
||||||
@@ -248,17 +323,40 @@ class SettingsStore(private val context: Context) {
|
|||||||
|
|
||||||
suspend fun clearSession() {
|
suspend fun clearSession() {
|
||||||
context.dataStore.edit {
|
context.dataStore.edit {
|
||||||
it.remove(Keys.SERVER_URL)
|
clearActiveSession(it)
|
||||||
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)
|
|
||||||
// Intentionally keep DEVICE_ID stable across sign-outs.
|
// 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) {
|
private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
|
||||||
preferences[Keys.SERVER_URL] = profile.serverUrl
|
preferences[Keys.SERVER_URL] = profile.serverUrl
|
||||||
preferences[Keys.TOKEN] = profile.token
|
preferences[Keys.TOKEN] = profile.token
|
||||||
@@ -268,6 +366,8 @@ class SettingsStore(private val context: Context) {
|
|||||||
else preferences[Keys.SERVER_ID] = profile.serverId
|
else preferences[Keys.SERVER_ID] = profile.serverId
|
||||||
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
|
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
|
||||||
else preferences[Keys.HOME_CACHE] = profile.homeCacheJson
|
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)
|
preferences.remove(Keys.LAST_BACKDROP_URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +384,8 @@ class SettingsStore(private val context: Context) {
|
|||||||
username = username,
|
username = username,
|
||||||
serverId = preferences[Keys.SERVER_ID],
|
serverId = preferences[Keys.SERVER_ID],
|
||||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
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],
|
updateRepo = preferences[Keys.UPDATE_REPO],
|
||||||
updateToken = preferences[Keys.UPDATE_TOKEN],
|
updateToken = preferences[Keys.UPDATE_TOKEN],
|
||||||
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
||||||
|
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
|
||||||
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
||||||
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
||||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
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,
|
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
|
|||||||
@@ -38,11 +38,84 @@ data class UserItemData(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class PlaybackReport(
|
data class PlaybackReport(
|
||||||
@SerialName("ItemId") val itemId: String,
|
@SerialName("ItemId") val itemId: String,
|
||||||
|
@SerialName("MediaSourceId") val mediaSourceId: String,
|
||||||
|
@SerialName("PlaySessionId") val playSessionId: String,
|
||||||
@SerialName("PositionTicks") val positionTicks: Long = 0,
|
@SerialName("PositionTicks") val positionTicks: Long = 0,
|
||||||
@SerialName("IsPaused") val isPaused: Boolean = false,
|
@SerialName("IsPaused") val isPaused: Boolean = false,
|
||||||
@SerialName("IsMuted") val isMuted: Boolean = false,
|
@SerialName("IsMuted") val isMuted: Boolean = false,
|
||||||
@SerialName("CanSeek") val canSeek: Boolean = true,
|
@SerialName("CanSeek") val canSeek: Boolean = true,
|
||||||
@SerialName("PlayMethod") val playMethod: String = "DirectPlay",
|
@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
|
@Serializable
|
||||||
@@ -52,9 +125,20 @@ data class Studio(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaStream(
|
data class MediaStream(
|
||||||
|
@SerialName("Index") val index: Int = -1,
|
||||||
@SerialName("Type") val type: String = "",
|
@SerialName("Type") val type: String = "",
|
||||||
@SerialName("Codec") val codec: String? = null,
|
@SerialName("Codec") val codec: String? = null,
|
||||||
@SerialName("Title") val title: 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("Width") val width: Int? = null,
|
||||||
@SerialName("Height") val height: Int? = null,
|
@SerialName("Height") val height: Int? = null,
|
||||||
@SerialName("VideoRange") val videoRange: String? = null,
|
@SerialName("VideoRange") val videoRange: String? = null,
|
||||||
@@ -62,6 +146,31 @@ data class MediaStream(
|
|||||||
@SerialName("Channels") val channels: Int? = null,
|
@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
|
@Serializable
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
@SerialName("Id") val id: String,
|
@SerialName("Id") val id: String,
|
||||||
@@ -76,11 +185,15 @@ data class BaseItem(
|
|||||||
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
|
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
|
||||||
@SerialName("Genres") val genres: List<String> = emptyList(),
|
@SerialName("Genres") val genres: List<String> = emptyList(),
|
||||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
||||||
|
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
|
||||||
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
|
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
|
||||||
@SerialName("BackdropImageTags") val backdropImageTags: List<String> = emptyList(),
|
@SerialName("BackdropImageTags") val backdropImageTags: List<String> = emptyList(),
|
||||||
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
|
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
|
||||||
@SerialName("SeriesId") val seriesId: String? = null,
|
@SerialName("SeriesId") val seriesId: String? = null,
|
||||||
@SerialName("SeriesName") val seriesName: 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("ParentBackdropItemId") val parentBackdropItemId: String? = null,
|
||||||
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(),
|
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(),
|
||||||
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
|
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
|
||||||
@@ -96,12 +209,26 @@ data class BaseItem(
|
|||||||
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
||||||
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
||||||
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
|
@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 isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
|
||||||
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
|
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
|
||||||
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
|
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
|
||||||
val isFavorite: Boolean get() = userData?.isFavorite == true
|
val isFavorite: Boolean get() = userData?.isFavorite == true
|
||||||
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
|
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. */
|
/** Runtime in whole minutes, or null when unknown. */
|
||||||
val runtimeMinutes: Int?
|
val runtimeMinutes: Int?
|
||||||
|
|||||||
@@ -104,6 +104,29 @@ data class GatewayUpdate(
|
|||||||
data class GatewayServiceStatus(
|
data class GatewayServiceStatus(
|
||||||
val maintenance: Boolean = false,
|
val maintenance: Boolean = false,
|
||||||
val message: String = "",
|
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`. */
|
/** Response of `GET /v1/recommendations`. */
|
||||||
@@ -117,12 +140,52 @@ data class GatewayItems(
|
|||||||
val items: List<BaseItem> = emptyList(),
|
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
|
@Serializable
|
||||||
data class GatewayPlayback(
|
data class GatewayPlayback(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val url: String,
|
val url: String,
|
||||||
val resumePositionMs: Long = 0,
|
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
|
@Serializable
|
||||||
@@ -151,4 +214,8 @@ data class GatewayPlaybackReport(
|
|||||||
val itemId: String,
|
val itemId: String,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
val isPaused: Boolean = false,
|
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.AuthResult
|
||||||
import com.ponzischeme89.memby.data.model.ItemsResult
|
import com.ponzischeme89.memby.data.model.ItemsResult
|
||||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
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 com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
@@ -31,6 +33,20 @@ interface EmbyApi {
|
|||||||
@Query("Fields") fields: String,
|
@Query("Fields") fields: String,
|
||||||
): com.ponzischeme89.memby.data.model.BaseItem
|
): 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")
|
@GET("Users/{userId}/Items/{itemId}/LocalTrailers")
|
||||||
suspend fun getLocalTrailers(
|
suspend fun getLocalTrailers(
|
||||||
@Path("userId") userId: String,
|
@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.GatewayItems
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
|
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.GatewayPlayback
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
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.GatewayRowEvents
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRows
|
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.GatewayServiceStatus
|
||||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||||
import com.ponzischeme89.memby.data.model.UserItemData
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
@@ -38,6 +41,10 @@ interface GatewayApi {
|
|||||||
@POST("v1/auth/logout")
|
@POST("v1/auth/logout")
|
||||||
suspend fun 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")
|
@GET("v1/home")
|
||||||
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
||||||
|
|
||||||
@@ -47,10 +54,22 @@ interface GatewayApi {
|
|||||||
@GET("v1/search")
|
@GET("v1/search")
|
||||||
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
|
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. */
|
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
|
||||||
@GET("v1/recommendations")
|
@GET("v1/recommendations")
|
||||||
suspend fun recommendations(): GatewayRows
|
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
|
* Whether this build should update. The version travels as a header on every request
|
||||||
* (see GatewayServiceFactory), so there is nothing to pass here.
|
* (see GatewayServiceFactory), so there is nothing to pass here.
|
||||||
@@ -65,14 +84,26 @@ interface GatewayApi {
|
|||||||
@GET("v1/items/{id}")
|
@GET("v1/items/{id}")
|
||||||
suspend fun item(@Path("id") itemId: String): BaseItem
|
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")
|
@GET("v1/items/{id}/playback")
|
||||||
suspend fun playback(
|
suspend fun playback(
|
||||||
@Path("id") itemId: String,
|
@Path("id") itemId: String,
|
||||||
@Query("type") itemType: String,
|
@Query("type") itemType: String,
|
||||||
@Query("title") title: String,
|
@Query("title") title: String,
|
||||||
@Query("resumePositionMs") resumePositionMs: Long,
|
@Query("resumePositionMs") resumePositionMs: Long,
|
||||||
|
@Query("subtitleIndex") subtitleIndex: Int? = null,
|
||||||
): GatewayPlayback
|
): 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")
|
@GET("v1/items/{id}/trailer")
|
||||||
suspend fun trailer(@Path("id") itemId: String): BaseItem
|
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
|
// The gateway decides whether this build needs updating, so every request
|
||||||
// says which build it is.
|
// says which build it is.
|
||||||
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
||||||
|
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
|
||||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
||||||
builder.header("Authorization", "Bearer $it")
|
builder.header("Authorization", "Bearer $it")
|
||||||
}
|
}
|
||||||
return chain.proceed(builder.build())
|
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.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.requiredWidth
|
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.LazyListState
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -44,17 +48,18 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
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.ChevronLeft
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
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.Home
|
||||||
import androidx.compose.material.icons.filled.LiveTv
|
import androidx.compose.material.icons.filled.LiveTv
|
||||||
import androidx.compose.material.icons.filled.Movie
|
import androidx.compose.material.icons.filled.Movie
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||||
import androidx.compose.material.icons.filled.Recommend
|
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.SentimentVerySatisfied
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.SkipNext
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
@@ -95,21 +102,27 @@ import coil.compose.AsyncImage
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
|
import com.ponzischeme89.memby.BuildConfig
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private val EmbyGreen = Color(0xFF52B54B)
|
private val EmbyGreen = Color(0xFF52B54B)
|
||||||
private val RailSurface = Color(0xF20C0F12)
|
private val RailSurface = Color(0xF20C0F12)
|
||||||
private val MutedText = Color(0xFFB7BDC3)
|
// High-contrast neutrals tuned for the app's near-black surfaces. Quiet remains visibly
|
||||||
private val QuietText = Color(0xFF8C949B)
|
// 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 TvRailCollapsedWidth = 54.dp
|
||||||
internal val TvRailExpandedWidth = 184.dp
|
internal val TvRailExpandedWidth = 184.dp
|
||||||
internal val TvRailContentShift = 112.dp
|
internal val TvRailContentShift = 112.dp
|
||||||
|
|
||||||
enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
||||||
HOME("Home", Icons.Default.Home),
|
HOME("Home", Icons.Default.Home),
|
||||||
|
FOR_YOU("For You", Icons.Default.AutoAwesome),
|
||||||
|
SEARCH("Search", Icons.Default.Search),
|
||||||
MOVIES("Movies", Icons.Default.Movie),
|
MOVIES("Movies", Icons.Default.Movie),
|
||||||
SHOWS("TV Shows", Icons.Default.Tv),
|
SHOWS("TV Shows", Icons.Default.Tv),
|
||||||
FAVORITES("Favourites", Icons.Default.Favorite),
|
FAVORITES("Favourites", Icons.Default.Favorite),
|
||||||
@@ -131,53 +144,44 @@ data class HomeBrowseRow(
|
|||||||
|
|
||||||
private data class HomeRowVisual(
|
private data class HomeRowVisual(
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
val colors: List<Color>,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||||
row.id == "continue" -> HomeRowVisual(
|
row.id == "continue" -> HomeRowVisual(
|
||||||
Icons.Default.PlayCircleFilled,
|
Icons.Default.PlayCircleFilled,
|
||||||
listOf(Color(0xFF3A7BD5), Color(0xFF6C4DFF)),
|
|
||||||
)
|
)
|
||||||
row.id == "next-up" -> HomeRowVisual(
|
row.id == "next-up" -> HomeRowVisual(
|
||||||
Icons.Default.SkipNext,
|
Icons.Default.SkipNext,
|
||||||
listOf(Color(0xFF00A896), Color(0xFF28C2A0)),
|
|
||||||
)
|
)
|
||||||
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
|
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
|
||||||
Icons.Default.Favorite,
|
Icons.Default.Favorite,
|
||||||
listOf(Color(0xFFFF5C8A), Color(0xFFB84DFF)),
|
|
||||||
)
|
)
|
||||||
row.id == "latest-movies" -> HomeRowVisual(
|
row.id == "latest-movies" -> HomeRowVisual(
|
||||||
Icons.Default.Movie,
|
Icons.Default.Movie,
|
||||||
listOf(Color(0xFFFF8A3D), Color(0xFFE34B6F)),
|
|
||||||
)
|
)
|
||||||
row.id == "sonarr-airing-today" -> HomeRowVisual(
|
row.id == "sonarr-airing-today" -> HomeRowVisual(
|
||||||
Icons.Default.CalendarMonth,
|
Icons.Default.CalendarMonth,
|
||||||
listOf(Color(0xFF00A9CE), Color(0xFF4169E1)),
|
|
||||||
)
|
)
|
||||||
row.id == "curated:apple-tv" -> HomeRowVisual(
|
row.id == "curated:apple-tv" -> HomeRowVisual(
|
||||||
Icons.Default.LiveTv,
|
Icons.Default.LiveTv,
|
||||||
listOf(Color(0xFF252A31), Color(0xFF66717E)),
|
|
||||||
)
|
)
|
||||||
row.id == "curated:drama-shows" -> HomeRowVisual(
|
row.id == "curated:drama-shows" -> HomeRowVisual(
|
||||||
Icons.Default.TheaterComedy,
|
Icons.Default.TheaterComedy,
|
||||||
listOf(Color(0xFF6A3FB5), Color(0xFFB64272)),
|
|
||||||
)
|
)
|
||||||
row.id == "curated:comedy-shows" -> HomeRowVisual(
|
row.id == "curated:comedy-shows" -> HomeRowVisual(
|
||||||
Icons.Default.SentimentVerySatisfied,
|
Icons.Default.SentimentVerySatisfied,
|
||||||
listOf(Color(0xFFFFB52E), Color(0xFFFF7433)),
|
|
||||||
)
|
)
|
||||||
row.id.startsWith("similar:") -> HomeRowVisual(
|
row.id.startsWith("similar:") -> HomeRowVisual(
|
||||||
Icons.Default.AutoAwesome,
|
Icons.Default.AutoAwesome,
|
||||||
listOf(Color(0xFF8A5CF6), Color(0xFFE458A3)),
|
|
||||||
)
|
)
|
||||||
row.id == "recommended" -> HomeRowVisual(
|
row.id == "recommended" -> HomeRowVisual(
|
||||||
Icons.Default.Recommend,
|
Icons.Default.Recommend,
|
||||||
listOf(Color(0xFF45B649), Color(0xFF00A896)),
|
)
|
||||||
|
row.id.startsWith("for-you:") -> HomeRowVisual(
|
||||||
|
Icons.Default.AutoAwesome,
|
||||||
)
|
)
|
||||||
else -> HomeRowVisual(
|
else -> HomeRowVisual(
|
||||||
Icons.Default.VideoLibrary,
|
Icons.Default.VideoLibrary,
|
||||||
listOf(Color(0xFF536976), Color(0xFF738A96)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,12 +190,34 @@ fun TvNavigationRail(
|
|||||||
selected: BrowseDestination,
|
selected: BrowseDestination,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
contentFocusRequester: FocusRequester,
|
|
||||||
onRailFocusChanged: (Boolean) -> Unit,
|
onRailFocusChanged: (Boolean) -> Unit,
|
||||||
onDestinationSelected: (BrowseDestination) -> Unit,
|
onDestinationSelected: (BrowseDestination) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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,
|
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
|
||||||
animationSpec = tween(160),
|
animationSpec = tween(160),
|
||||||
label = "navigation-rail-width",
|
label = "navigation-rail-width",
|
||||||
@@ -207,10 +233,30 @@ fun TvNavigationRail(
|
|||||||
// The parent deliberately reports only the collapsed footprint to the
|
// The parent deliberately reports only the collapsed footprint to the
|
||||||
// home Row. requiredWidth lets the focused surface draw outward without
|
// home Row. requiredWidth lets the focused surface draw outward without
|
||||||
// remeasuring gallery cards or clipping their labels to 54dp.
|
// remeasuring gallery cards or clipping their labels to 54dp.
|
||||||
.wrapContentSize(Alignment.TopStart, unbounded = true)
|
// Only width may escape the collapsed 54dp footprint. Unbounding height
|
||||||
.requiredWidth(railWidth)
|
// 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()
|
.fillMaxHeight()
|
||||||
.background(RailSurface)
|
.background(RailSurface)
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
val hasFocus = focusState.hasFocus
|
||||||
|
if (railHasFocus != hasFocus) {
|
||||||
|
railHasFocus = hasFocus
|
||||||
|
onRailFocusChanged(hasFocus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusGroup()
|
||||||
.padding(horizontal = 5.dp, vertical = 15.dp),
|
.padding(horizontal = 5.dp, vertical = 15.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start,
|
||||||
) {
|
) {
|
||||||
@@ -222,7 +268,15 @@ fun TvNavigationRail(
|
|||||||
Image(
|
Image(
|
||||||
painter = painterResource(R.drawable.emby_logo),
|
painter = painterResource(R.drawable.emby_logo),
|
||||||
contentDescription = "Memby",
|
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) {
|
if (expanded) {
|
||||||
Column(verticalArrangement = Arrangement.Center) {
|
Column(verticalArrangement = Arrangement.Center) {
|
||||||
@@ -251,12 +305,20 @@ fun TvNavigationRail(
|
|||||||
selected = destination == selected,
|
selected = destination == selected,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
|
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
|
||||||
contentFocusRequester = contentFocusRequester,
|
onFocused = {},
|
||||||
onFocused = { onRailFocusChanged(true) },
|
|
||||||
onClick = { onDestinationSelected(destination) },
|
onClick = { onDestinationSelected(destination) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
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,
|
destination: BrowseDestination,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
contentFocusRequester: FocusRequester,
|
|
||||||
onFocused: () -> Unit,
|
onFocused: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
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 {
|
targetValue = when {
|
||||||
focused -> Color.White.copy(alpha = 0.13f)
|
focused -> Color.White.copy(alpha = 0.13f)
|
||||||
selected -> EmbyGreen.copy(alpha = 0.10f)
|
selected -> EmbyGreen.copy(alpha = 0.10f)
|
||||||
@@ -281,7 +344,7 @@ fun ExpandableNavigationItem(
|
|||||||
animationSpec = tween(85),
|
animationSpec = tween(85),
|
||||||
label = "navigation-item-background",
|
label = "navigation-item-background",
|
||||||
)
|
)
|
||||||
val foreground by animateColorAsState(
|
val foreground = animateColorAsState(
|
||||||
targetValue = when {
|
targetValue = when {
|
||||||
focused -> Color.White
|
focused -> Color.White
|
||||||
selected -> EmbyGreen
|
selected -> EmbyGreen
|
||||||
@@ -294,13 +357,12 @@ fun ExpandableNavigationItem(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(44.dp)
|
.height(44.dp)
|
||||||
.focusProperties { right = contentFocusRequester }
|
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
if (it.isFocused) onFocused()
|
if (it.isFocused) onFocused()
|
||||||
}
|
}
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(background)
|
.drawBehind { drawRect(background.value) }
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.semantics { contentDescription = destination.label }
|
.semantics { contentDescription = destination.label }
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 8.dp),
|
||||||
@@ -308,7 +370,7 @@ fun ExpandableNavigationItem(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
|
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) {
|
if (selected) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -322,7 +384,7 @@ fun ExpandableNavigationItem(
|
|||||||
if (expanded) {
|
if (expanded) {
|
||||||
Text(
|
Text(
|
||||||
destination.label,
|
destination.label,
|
||||||
color = foreground,
|
color = foreground.value,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -390,7 +452,6 @@ fun MediaMetadataPanel(
|
|||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
sectionLabel: String,
|
sectionLabel: String,
|
||||||
contentFocusRequester: FocusRequester,
|
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
onPlay: (BaseItem) -> Unit,
|
onPlay: (BaseItem) -> Unit,
|
||||||
onContentFocused: () -> Unit,
|
onContentFocused: () -> Unit,
|
||||||
@@ -414,7 +475,6 @@ fun MediaMetadataPanel(
|
|||||||
onClick = { item?.let(onPlay) },
|
onClick = { item?.let(onPlay) },
|
||||||
enabled = item != null,
|
enabled = item != null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(contentFocusRequester)
|
|
||||||
.focusProperties { left = navigationFocusRequester }
|
.focusProperties { left = navigationFocusRequester }
|
||||||
.onFocusChanged { if (it.isFocused) onContentFocused() },
|
.onFocusChanged { if (it.isFocused) onContentFocused() },
|
||||||
) {
|
) {
|
||||||
@@ -477,7 +537,7 @@ fun MediaDetailsOverlay(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.fillMaxWidth(0.68f)
|
.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,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -517,16 +577,24 @@ fun MediaDetailsOverlay(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
if (item.membyAiringToday) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
MediaBadge("AIRING TODAY")
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||||
color = Color(0xFFD8DCDF),
|
color = Color(0xFFD8DCDF),
|
||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
lineHeight = 23.sp,
|
lineHeight = 23.sp,
|
||||||
maxLines = 4,
|
maxLines = 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onPlay(item) },
|
onClick = { onPlay(item) },
|
||||||
@@ -636,7 +704,21 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) {
|
|||||||
if (facts.isNotEmpty()) {
|
if (facts.isNotEmpty()) {
|
||||||
Text(facts.joinToString(" • "), color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
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()) {
|
if (badges.isNotEmpty()) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||||
badges.forEach { MediaBadge(it) }
|
badges.forEach { MediaBadge(it) }
|
||||||
@@ -785,6 +867,11 @@ fun MediaRow(
|
|||||||
) {
|
) {
|
||||||
val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
||||||
val scope = rememberCoroutineScope()
|
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 (
|
val pageSize = if (
|
||||||
row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT
|
row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT
|
||||||
) 6 else 4
|
) 6 else 4
|
||||||
@@ -798,8 +885,8 @@ fun MediaRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Brush.linearGradient(visual.colors))
|
.background(Color.White.copy(alpha = 0.08f))
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape),
|
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -814,13 +901,13 @@ fun MediaRow(
|
|||||||
row.title,
|
row.title,
|
||||||
color = Color(0xFFF1F3F4),
|
color = Color(0xFFF1F3F4),
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (row.items.isNotEmpty()) {
|
if (row.items.isNotEmpty()) {
|
||||||
GalleryJumpButton(
|
GalleryJumpButton(
|
||||||
forward = false,
|
forward = false,
|
||||||
enabled = rowState.firstVisibleItemIndex > 0,
|
enabled = canScrollBack,
|
||||||
onClick = {
|
onClick = {
|
||||||
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
|
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
|
||||||
scope.launch { rowState.scrollToItem(target) }
|
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() -> {
|
row.items.isEmpty() -> {
|
||||||
Text(
|
Text(
|
||||||
row.emptyMessage,
|
row.emptyMessage,
|
||||||
@@ -865,7 +958,11 @@ fun MediaRow(
|
|||||||
state = rowState,
|
state = rowState,
|
||||||
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
|
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.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(
|
itemsIndexed(
|
||||||
row.items,
|
row.items,
|
||||||
@@ -874,7 +971,6 @@ fun MediaRow(
|
|||||||
) { index, item ->
|
) { index, item ->
|
||||||
var cardModifier: Modifier = Modifier
|
var cardModifier: Modifier = Modifier
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
cardModifier = cardModifier.focusProperties { left = navigationFocusRequester }
|
|
||||||
if (contentEntryFocusRequester != null) {
|
if (contentEntryFocusRequester != null) {
|
||||||
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
|
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
|
@Composable
|
||||||
private fun GalleryJumpButton(
|
private fun GalleryJumpButton(
|
||||||
forward: Boolean,
|
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
|
@Composable
|
||||||
fun LandscapeMediaCard(
|
fun LandscapeMediaCard(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
@@ -1138,12 +1422,18 @@ private fun MediaCard(
|
|||||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (item.membyAiringToday) {
|
||||||
|
MediaBadge(
|
||||||
|
"AIRING TODAY",
|
||||||
|
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
item.name,
|
item.name,
|
||||||
color = if (focused) Color.White else Color(0xFFD1D5D8),
|
color = if (focused) Color.White else Color(0xFFE1E5E8),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||||
@@ -1153,6 +1443,7 @@ private fun MediaCard(
|
|||||||
cardSubtitle(item, showProgress, position),
|
cardSubtitle(item, showProgress, position),
|
||||||
color = QuietText,
|
color = QuietText,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
||||||
@@ -1190,14 +1481,14 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
|
|||||||
fun FocusScaleContainer(
|
fun FocusScaleContainer(
|
||||||
onFocused: () -> Unit,
|
onFocused: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: (() -> Unit)? = null,
|
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
content: @Composable BoxScope.(focused: Boolean) -> Unit,
|
content: @Composable BoxScope.(focused: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
var remoteLongPressHandled by remember { mutableStateOf(false) }
|
var remoteLongPressHandled by remember { mutableStateOf(false) }
|
||||||
val scale by animateFloatAsState(
|
val scale = animateFloatAsState(
|
||||||
targetValue = if (focused) 1.025f else 1f,
|
targetValue = if (focused) 1.025f else 1f,
|
||||||
animationSpec = tween(95),
|
animationSpec = tween(95),
|
||||||
label = "media-card-focus",
|
label = "media-card-focus",
|
||||||
@@ -1205,16 +1496,13 @@ fun FocusScaleContainer(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.zIndex(if (focused) 1f else 0f)
|
.zIndex(if (focused) 1f else 0f)
|
||||||
.then(
|
// Always a graphicsLayer, and the scale read inside it. Branching on
|
||||||
if (focused || scale != 1f) {
|
// `scale != 1f` meant reading the animated value during composition, so
|
||||||
Modifier.graphicsLayer {
|
// every card recomposed on each frame of its own focus animation.
|
||||||
scaleX = scale
|
.graphicsLayer {
|
||||||
scaleY = scale
|
scaleX = scale.value
|
||||||
}
|
scaleY = scale.value
|
||||||
} else {
|
}
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
if (it.isFocused) onFocused()
|
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 {
|
private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when {
|
||||||
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
|
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
|
||||||
|
!item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason
|
||||||
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
|
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
|
||||||
item.isEpisode -> item.seriesName ?: "Up next"
|
item.isEpisode -> item.seriesName ?: "Up next"
|
||||||
item.productionYear != null && item.runtimeMinutes != null ->
|
item.productionYear != null && item.runtimeMinutes != null ->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.ponzischeme89.memby.data.EmbyRepository
|
import com.ponzischeme89.memby.data.EmbyRepository
|
||||||
import com.ponzischeme89.memby.data.HomeCache
|
import com.ponzischeme89.memby.data.HomeCache
|
||||||
|
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||||
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
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() {
|
class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||||
private val refreshMutex = Mutex()
|
private val refreshMutex = Mutex()
|
||||||
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
||||||
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
||||||
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
||||||
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
||||||
|
private val _forYou = MutableStateFlow(ForYouUiState())
|
||||||
|
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
|
||||||
private var metadataJob: Job? = null
|
private var metadataJob: Job? = null
|
||||||
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
|
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
|
||||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
|
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())
|
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() {
|
fun refreshAll() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
refreshMutex.withLock {
|
refreshMutex.withLock {
|
||||||
@@ -189,18 +224,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
private suspend fun loadBatchHome() {
|
private suspend fun loadBatchHome() {
|
||||||
runCatching { repository.getHome() }
|
runCatching { repository.getHome() }
|
||||||
.onSuccess { home ->
|
.onSuccess { home ->
|
||||||
|
val taggedHome = home.withAiringTodayTags()
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
current.copy(
|
current.copy(
|
||||||
continueWatching = home.continueWatching,
|
continueWatching = taggedHome.continueWatching,
|
||||||
nextUp = home.nextUp,
|
nextUp = taggedHome.nextUp,
|
||||||
favorites = home.favorites,
|
favorites = taggedHome.favorites,
|
||||||
latestMovies = home.latestMovies,
|
latestMovies = taggedHome.latestMovies,
|
||||||
// Recommendation rows are built in the background by the gateway,
|
// Recommendation rows are built in the background by the gateway,
|
||||||
// so an early response can arrive without them. Keeping the rows
|
// so an early response can arrive without them. Keeping the rows
|
||||||
// we already had stops the strip flickering out and back in.
|
// 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(),
|
loading = emptySet(),
|
||||||
hasRefreshError = home.partial,
|
hasRefreshError = taggedHome.partial,
|
||||||
statusMessage = null,
|
statusMessage = null,
|
||||||
// A successful response is the only thing that clears the
|
// A successful response is the only thing that clears the
|
||||||
// maintenance screen, so a retry that fails keeps it up.
|
// 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) {
|
if (_focusedItem.value == null) {
|
||||||
initialFocusedItem(_state.value)?.let(::focusItem)
|
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 ->
|
.onFailure { error ->
|
||||||
val maintenance = isMaintenanceError(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.
|
* Updates local metadata immediately, then enriches it only after focus settles.
|
||||||
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
|
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
|
||||||
*/
|
*/
|
||||||
fun focusItem(item: BaseItem) {
|
fun focusItem(item: BaseItem) {
|
||||||
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
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?.cancel()
|
||||||
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
||||||
@@ -246,9 +303,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
val details = runCatching {
|
val details = runCatching {
|
||||||
repository.getItemDetails(item.id)
|
repository.getItemDetails(item.id)
|
||||||
}.getOrNull() ?: return@launch
|
}.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) {
|
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 {
|
class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -58,7 +59,7 @@ import kotlinx.coroutines.delay
|
|||||||
private val MaintenanceAccent = Color(0xFF52B54B)
|
private val MaintenanceAccent = Color(0xFF52B54B)
|
||||||
private val MaintenanceTitle = Color(0xFFF2F5F7)
|
private val MaintenanceTitle = Color(0xFFF2F5F7)
|
||||||
private val MaintenanceBody = Color(0xFFAEB7BF)
|
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. */
|
/** How long between automatic retries while the gateway is down. */
|
||||||
private const val RETRY_SECONDS = 30
|
private const val RETRY_SECONDS = 30
|
||||||
@@ -117,7 +118,7 @@ fun MaintenanceScreen(
|
|||||||
label = "maintenance-entrance",
|
label = "maintenance-entrance",
|
||||||
)
|
)
|
||||||
|
|
||||||
var secondsLeft by remember { mutableStateOf(RETRY_SECONDS) }
|
var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) }
|
||||||
LaunchedEffect(message) {
|
LaunchedEffect(message) {
|
||||||
// Restarts whenever the message changes, so a failed retry resets the clock.
|
// Restarts whenever the message changes, so a failed retry resets the clock.
|
||||||
secondsLeft = RETRY_SECONDS
|
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. */
|
/** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MaintenanceBackdrop(glow: Float) {
|
private fun MaintenanceBackdrop(glow: Float) {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import kotlinx.coroutines.launch
|
|||||||
private val UpdateAccent = Color(0xFF52B54B)
|
private val UpdateAccent = Color(0xFF52B54B)
|
||||||
private val UpdateTitle = Color(0xFFF2F5F7)
|
private val UpdateTitle = Color(0xFFF2F5F7)
|
||||||
private val UpdateBody = Color(0xFFAEB7BF)
|
private val UpdateBody = Color(0xFFAEB7BF)
|
||||||
private val UpdateFaint = Color(0xFF7E888F)
|
private val UpdateFaint = Color(0xFFA2ADB5)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The update prompt, shown over the home screen.
|
* 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.Context
|
||||||
import android.content.Intent
|
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. */
|
/** Opens an item in Emby's installed Android or Android TV client. */
|
||||||
internal object EmbyAppLauncher {
|
internal object EmbyAppLauncher {
|
||||||
@@ -19,7 +19,7 @@ internal object EmbyAppLauncher {
|
|||||||
)
|
)
|
||||||
for (packageName in packageCandidates) {
|
for (packageName in packageCandidates) {
|
||||||
for (link in links) {
|
for (link in links) {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
|
||||||
.setPackage(packageName)
|
.setPackage(packageName)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
if (intent.resolveActivity(context.packageManager) != null) {
|
if (intent.resolveActivity(context.packageManager) != null) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.core.graphics.get
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -625,7 +627,7 @@ private fun Slideshow(
|
|||||||
/** A high-legibility, always-current clock beside the slide-progress indicator. */
|
/** A high-legibility, always-current clock beside the slide-progress indicator. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CurrentTime(modifier: Modifier = Modifier) {
|
private fun CurrentTime(modifier: Modifier = Modifier) {
|
||||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
now = System.currentTimeMillis()
|
now = System.currentTimeMillis()
|
||||||
@@ -890,7 +892,7 @@ private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
|||||||
var darkPixels = 0
|
var darkPixels = 0
|
||||||
for (y in 0 until bitmap.height step 2) {
|
for (y in 0 until bitmap.height step 2) {
|
||||||
for (x in 0 until bitmap.width 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
|
if (android.graphics.Color.alpha(pixel) < 48) continue
|
||||||
opaquePixels++
|
opaquePixels++
|
||||||
val luminance = (
|
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.LocalTextStyle
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.darkColorScheme
|
import androidx.tv.material3.darkColorScheme
|
||||||
@@ -17,11 +18,23 @@ private val EmbyColors = darkColorScheme(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MembyTheme(content: @Composable () -> Unit) {
|
fun MembyTheme(content: @Composable () -> Unit) {
|
||||||
// Android's native medium face is always available on TV, so it looks refined
|
// Use Android's complete Roboto-backed sans family rather than pinning every label
|
||||||
// without a downloaded font or a first-render font swap.
|
// to sans-serif-medium. The generic family lets Compose select real regular,
|
||||||
val tvFont = FontFamily(Typeface.create("sans-serif-medium", Typeface.NORMAL))
|
// 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) {
|
MaterialTheme(colorScheme = EmbyColors) {
|
||||||
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = tvFont)) {
|
CompositionLocalProvider(LocalTextStyle provides appTextStyle) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package com.ponzischeme89.memby.update
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -165,7 +165,7 @@ class UpdateChecker(private val context: Context) {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val intent = Intent(
|
val intent = Intent(
|
||||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
Uri.parse("package:${context.packageName}"),
|
"package:${context.packageName}".toUri(),
|
||||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
context.startActivity(intent)
|
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"?>
|
<?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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:width="320dp"
|
android:width="320dp"
|
||||||
android:height="180dp"
|
android:height="180dp"
|
||||||
|
tools:ignore="VectorRaster"
|
||||||
android:viewportWidth="320"
|
android:viewportWidth="320"
|
||||||
android:viewportHeight="180">
|
android:viewportHeight="180">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -2,8 +2,45 @@
|
|||||||
<string name="player_title_logo">Title logo</string>
|
<string name="player_title_logo">Title logo</string>
|
||||||
<string name="playback_loading">Starting playback…</string>
|
<string name="playback_loading">Starting playback…</string>
|
||||||
<string name="playback_loading_hint">Connecting directly to Emby</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="app_name">Memby</string>
|
||||||
<string name="screensaver_name">Memby Screensaver</string>
|
<string name="screensaver_name">Memby Screensaver</string>
|
||||||
<string name="dream_description">Memby movie & TV backdrops</string>
|
|
||||||
<string name="developer_name">ponzischeme89</string>
|
<string name="developer_name">ponzischeme89</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.Memby" parent="@android:style/Theme.Material.NoActionBar">
|
<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:windowBackground">@android:color/black</item>
|
||||||
<item name="android:statusBarColor">@android:color/black</item>
|
<item name="android:statusBarColor">@android:color/black</item>
|
||||||
<item name="android:navigationBarColor">@android:color/black</item>
|
<item name="android:navigationBarColor">@android:color/black</item>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.ponzischeme89.memby.data
|
package com.ponzischeme89.memby.data
|
||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
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.GatewayPlayback
|
||||||
|
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||||
|
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -23,6 +27,17 @@ class GatewayPayloadTest {
|
|||||||
explicitNulls = false
|
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
|
@Test
|
||||||
fun `decodes a home payload with emby-shaped items`() {
|
fun `decodes a home payload with emby-shaped items`() {
|
||||||
val payload = """
|
val payload = """
|
||||||
@@ -79,6 +94,30 @@ class GatewayPayloadTest {
|
|||||||
assertEquals("Solaris", home.rows.last().items.single().name)
|
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
|
@Test
|
||||||
fun `decodes the informational Sonarr schedule row`() {
|
fun `decodes the informational Sonarr schedule row`() {
|
||||||
val payload = """
|
val payload = """
|
||||||
@@ -135,6 +174,15 @@ class GatewayPayloadTest {
|
|||||||
assertTrue(home.partial)
|
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
|
@Test
|
||||||
fun `decodes a playback response`() {
|
fun `decodes a playback response`() {
|
||||||
val playback = json.decodeFromString<GatewayPlayback>(
|
val playback = json.decodeFromString<GatewayPlayback>(
|
||||||
@@ -145,6 +193,48 @@ class GatewayPayloadTest {
|
|||||||
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
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
|
@Test
|
||||||
fun `device allowance response becomes a specific sign-in error`() {
|
fun `device allowance response becomes a specific sign-in error`() {
|
||||||
val error = parseDeviceLimit(
|
val error = parseDeviceLimit(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ponzischeme89.memby.data
|
package com.ponzischeme89.memby.data
|
||||||
|
|
||||||
|
import com.ponzischeme89.memby.data.model.DeviceProfile
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -13,4 +14,16 @@ class PlaybackReportMathTest {
|
|||||||
fun negativePositionsAreClamped() {
|
fun negativePositionsAreClamped() {
|
||||||
assertEquals(0L, millisecondsToTicks(-1L))
|
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)
|
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 {
|
class ServerHomeRowsTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow(
|
private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow(
|
||||||
id = id,
|
id = id,
|
||||||
title = id.replaceFirstChar(Char::uppercase),
|
title = id.replaceFirstChar(Char::uppercase),
|
||||||
@@ -69,6 +71,14 @@ class ServerHomeRowsTest {
|
|||||||
assertEquals("Favorites", personalizedFavoritesTitle(null))
|
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
|
@Test
|
||||||
fun `disabling a section hides its rows but never the recommendations`() {
|
fun `disabling a section hides its rows but never the recommendations`() {
|
||||||
val settings = Settings(homeSections = "continue")
|
val settings = Settings(homeSections = "continue")
|
||||||
@@ -93,6 +103,91 @@ class ServerHomeRowsTest {
|
|||||||
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
|
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
|
@Test
|
||||||
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
|
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
|
||||||
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
|
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
|
||||||
@@ -160,7 +255,7 @@ class ServerHomeRowsTest {
|
|||||||
fun `a cache written before rows existed still decodes`() {
|
fun `a cache written before rows existed still decodes`() {
|
||||||
val legacy = """{"continueWatching":[{"Id":"a"}],"favorites":[],"nextUp":[],"latestMovies":[]}"""
|
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())
|
assertTrue(restored.rows.isEmpty())
|
||||||
assertEquals("a", restored.continueWatching.single().id)
|
assertEquals("a", restored.continueWatching.single().id)
|
||||||
|
|||||||
+14
-2
@@ -5,9 +5,12 @@ services:
|
|||||||
build: ./server
|
build: ./server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
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:
|
environment:
|
||||||
MEMBY_LISTEN_ADDR: ":8080"
|
MEMBY_LISTEN_ADDR: ":32768"
|
||||||
# INFO keeps Docker logs concise. Temporarily use DEBUG to include successful
|
# INFO keeps Docker logs concise. Temporarily use DEBUG to include successful
|
||||||
# health checks, maintenance polling and artwork requests.
|
# health checks, maintenance polling and artwork requests.
|
||||||
MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}"
|
MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}"
|
||||||
@@ -43,6 +46,15 @@ services:
|
|||||||
MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}"
|
MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}"
|
||||||
MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}"
|
MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}"
|
||||||
MEMBY_SONARR_TTL: "${MEMBY_SONARR_TTL:-5m}"
|
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:
|
volumes:
|
||||||
- memby-releases:/data/releases
|
- memby-releases:/data/releases
|
||||||
depends_on:
|
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
|
cp .env.example .env # from the repo root
|
||||||
$EDITOR .env # set MEMBY_EMBY_URL and POSTGRES_PASSWORD
|
$EDITOR .env # set MEMBY_EMBY_URL and POSTGRES_PASSWORD
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
curl localhost:8080/readyz
|
curl localhost:32768/readyz
|
||||||
```
|
```
|
||||||
|
|
||||||
Then build the TV app against it:
|
Then build the TV app against it:
|
||||||
|
|
||||||
```powershell
|
```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
|
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`.
|
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
|
## Logs
|
||||||
|
|
||||||
The server writes one compact, structured text line per event, designed for
|
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 |
|
| GET | `/v1/auth/policy` | Public client allowance used by the sign-in screen |
|
||||||
| POST | `/v1/auth/logout` | Retire this device's token |
|
| POST | `/v1/auth/logout` | Retire this device's token |
|
||||||
| GET | `/v1/auth/session` | Confirm a stored token is still valid |
|
| 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/home?limit=` | **Every launcher row in one response** |
|
||||||
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
|
| 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/update` | Whether this client (per `X-Memby-Version`) must update |
|
||||||
| GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request |
|
| 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}` | 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}/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 |
|
| GET | `/v1/items/{id}/trailer` | First local trailer, or 404 |
|
||||||
| POST | `/v1/items/{id}/favorite` | `{"value":true}` |
|
| POST | `/v1/items/{id}/favorite` | `{"value":true}` |
|
||||||
| POST | `/v1/items/{id}/played` | `{"value":true}` |
|
| POST | `/v1/items/{id}/played` | `{"value":true}` |
|
||||||
@@ -77,7 +102,7 @@ headers attached.
|
|||||||
### Server-driven rows
|
### Server-driven rows
|
||||||
|
|
||||||
`/v1/home` returns a `rows` array — order, titles and kinds all decided here — plus the
|
`/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
|
```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": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]},
|
||||||
{"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "items": [...]},
|
{"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "items": [...]},
|
||||||
{"id": "favorites", "title": "Favourites", "kind": "favorites", "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": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]},
|
||||||
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]},
|
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]},
|
||||||
{"id": "curated:apple-tv", "title": "Apple TV+ Shows", "kind": "shows", "items": [...]},
|
{"id": "curated:apple-tv", "title": "Apple TV+ Shows", "kind": "shows", "items": [...]},
|
||||||
{"id": "curated:drama-shows", "title": "Drama 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": [...]}
|
{"id": "curated:comedy-shows", "title": "Comedy TV Shows", "kind": "shows", "items": [...]}
|
||||||
],
|
],
|
||||||
"continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...],
|
"continueWatching": [...], "favorites": [...], "latestMovies": [...],
|
||||||
"partial": false
|
"partial": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The TV renders whatever arrives, so a new row ships without an app release. `kind` picks
|
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 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.
|
invented — nobody opted out of a row that did not exist when they last opened Settings.
|
||||||
|
|
||||||
### Recommendations
|
### 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;
|
filter runs against Postgres. Emby's exact studio naming is preserved during import;
|
||||||
Apple matching accepts `Apple TV+`, `Apple TV Plus` and `Apple Studios`.
|
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
|
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.
|
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
|
`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
|
sent to the TV. The shared Redis entry is refreshed every five minutes by default, not
|
||||||
once per user.
|
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
|
## 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
|
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
|
`/admin` route 404s so it cannot be left exposed by accident. Loading the page establishes
|
||||||
field at the top of the page; it is kept in the browser's local storage and sent as a
|
a persistent HttpOnly admin cookie automatically, so the configured token survives server
|
||||||
bearer header. Put the whole path behind your reverse proxy's own auth as well if the
|
and browser reboots without being pasted into the page. Anyone who can load this local-only
|
||||||
gateway is reachable from outside the LAN.
|
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 |
|
| Method | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/admin/` | The page |
|
| GET | `/admin/` | The page |
|
||||||
| GET | `/admin/api/status` | Library counts, sync history, maintenance state |
|
| GET | `/admin/api/status` | Library counts, sync history, maintenance state |
|
||||||
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
|
| 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/maintenance` | `{"enabled":true,"message":"…"}` |
|
||||||
| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` |
|
| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` |
|
||||||
| GET | `/admin/api/analytics?days=7` | Row engagement |
|
| 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
|
served but never cached. The `r:` namespace is deliberately excluded from that wipe (see
|
||||||
Recommendations above).
|
Recommendations above).
|
||||||
|
|
||||||
Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache,
|
Postgres is the durable half: it holds gateway sessions, the imported Emby catalogue,
|
||||||
losing Postgres signs everyone out. A session is unique per Emby user and stable device
|
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
|
ID; signing the same TV in again rotates its token, while a new TV is rejected once
|
||||||
`MEMBY_MAX_CLIENTS_PER_USER` is reached.
|
`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_EMBY_PUBLIC_URL` | = `MEMBY_EMBY_URL` | What TVs stream from |
|
||||||
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
|
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
|
||||||
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
|
| `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_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_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
|
||||||
| `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list |
|
| `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_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_TTL` | `2h` | How long computed recommendation rows stay warm |
|
||||||
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
|
| `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_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled |
|
||||||
| `MEMBY_PUBLIC_URL` | *empty* | Public gateway origin used for APK download links |
|
| `MEMBY_PUBLIC_URL` | *empty* | Public gateway origin used for APK download links |
|
||||||
| `MEMBY_RELEASE_DIR` | `/data/releases` | Persistent signed APK directory |
|
| `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_URL` | *empty* | Sonarr address reachable by the gateway; empty disables integration |
|
||||||
| `MEMBY_SONARR_API_KEY` | *empty* | Sonarr Settings → General → Security API key |
|
| `MEMBY_SONARR_API_KEY` | *empty* | Sonarr Settings → General → Security API key |
|
||||||
| `MEMBY_SONARR_TTL` | `5m` | Shared Redis lifetime for today's calendar |
|
| `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_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_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this |
|
||||||
| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis |
|
| `MEMBY_SESSION_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/cache"
|
||||||
"github.com/ponzischeme89/memby/server/internal/config"
|
"github.com/ponzischeme89/memby/server/internal/config"
|
||||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
"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/library"
|
||||||
"github.com/ponzischeme89/memby/server/internal/logging"
|
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -41,15 +43,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
|
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)
|
log.Error("fatal", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(log *slog.Logger) error {
|
func run(log *slog.Logger, events *logging.Buffer) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -119,21 +121,54 @@ func run(log *slog.Logger) error {
|
|||||||
// Candidates come from the imported library when one exists, which keeps the
|
// Candidates come from the imported library when one exists, which keeps the
|
||||||
// recommendation rebuild off Emby entirely.
|
// recommendation rebuild off Emby entirely.
|
||||||
recommender.Library = st
|
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{
|
syncer := library.NewSyncer(embyClient, st, emby.Credentials{
|
||||||
UserID: cfg.SyncUserID,
|
UserID: cfg.SyncUserID,
|
||||||
Token: cfg.SyncAPIKey,
|
Token: cfg.SyncAPIKey,
|
||||||
DeviceID: "memby-gateway-sync",
|
DeviceID: "memby-gateway-sync",
|
||||||
}, log)
|
}, 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{
|
server := api.New(cfg, api.Deps{
|
||||||
Emby: embyClient,
|
Emby: embyClient,
|
||||||
Store: st,
|
Store: st,
|
||||||
Cache: ca,
|
Cache: ca,
|
||||||
Recommender: recommender,
|
Recommender: recommender,
|
||||||
|
ForYou: forYouService,
|
||||||
Sonarr: sonarrClient,
|
Sonarr: sonarrClient,
|
||||||
Syncer: syncer,
|
Syncer: syncer,
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Events: events,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := server.LoadMaintenance(ctx); err != nil {
|
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)
|
go pruneAnalytics(ctx, st, cfg.AnalyticsRetention, log)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
@@ -195,7 +249,7 @@ func probeHealth() error {
|
|||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
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 {
|
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
|
||||||
addr = "127.0.0.1" + addr[idx:]
|
addr = "127.0.0.1" + addr[idx:]
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-15
@@ -6,6 +6,7 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
//go:embed admin.html
|
//go:embed admin.html
|
||||||
var adminPage []byte
|
var adminPage []byte
|
||||||
|
|
||||||
|
const adminCookieName = "memby_admin"
|
||||||
|
|
||||||
// adminRoutes is the operator interface: library imports, the maintenance switch, and
|
// 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
|
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
|
||||||
// left exposed by accident.
|
// left exposed by accident.
|
||||||
@@ -26,7 +29,9 @@ func (s *Server) adminRoutes() http.Handler {
|
|||||||
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
|
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
|
||||||
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
|
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/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/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/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||||
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
||||||
@@ -34,7 +39,22 @@ func (s *Server) adminRoutes() http.Handler {
|
|||||||
return mux
|
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 {
|
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.cfg.AdminToken == "" {
|
if s.cfg.AdminToken == "" {
|
||||||
@@ -42,6 +62,11 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
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 {
|
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
|
||||||
writeError(w, http.StatusUnauthorized, "invalid admin token")
|
writeError(w, http.StatusUnauthorized, "invalid admin token")
|
||||||
return
|
return
|
||||||
@@ -55,20 +80,30 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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")
|
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.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(adminPage)
|
_, _ = w.Write(adminPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminStatus struct {
|
type adminStatus struct {
|
||||||
Maintenance store.Maintenance `json:"maintenance"`
|
Maintenance store.Maintenance `json:"maintenance"`
|
||||||
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||||
Library store.LibraryStats `json:"library"`
|
Library store.LibraryStats `json:"library"`
|
||||||
SyncRunning bool `json:"syncRunning"`
|
SyncRunning bool `json:"syncRunning"`
|
||||||
Runs []store.SyncRun `json:"runs"`
|
Runs []store.SyncRun `json:"runs"`
|
||||||
SyncEvery string `json:"syncEvery"`
|
SyncEvery string `json:"syncEvery"`
|
||||||
|
ForYou store.ForYouStats `json:"forYou"`
|
||||||
|
ForYouRunning bool `json:"forYouRunning"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
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
|
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{
|
writeJSON(w, http.StatusOK, adminStatus{
|
||||||
Maintenance: s.maintenance.get(),
|
Maintenance: s.maintenance.get(),
|
||||||
UpdatePolicy: s.updatePolicy.get(),
|
UpdatePolicy: s.updatePolicy.get(),
|
||||||
Library: stats,
|
Library: stats,
|
||||||
SyncRunning: s.syncer.Running(),
|
SyncRunning: s.syncer.Running(),
|
||||||
Runs: runs,
|
Runs: runs,
|
||||||
SyncEvery: s.cfg.SyncInterval.String(),
|
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})
|
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 {
|
type maintenanceRequest struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|||||||
+182
-13
@@ -15,7 +15,7 @@
|
|||||||
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
|
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
|
||||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
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; }
|
h1 { font-size: 24px; margin: 0; }
|
||||||
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
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; }
|
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||||
@@ -47,6 +47,32 @@
|
|||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.banner { padding: 11px 14px; border-radius: 8px; background: #3a1d1c; color: #ffb3ad; display: none; }
|
.banner { padding: 11px 14px; border-radius: 8px; background: #3a1d1c; color: #ffb3ad; display: none; }
|
||||||
.banner.show { display: block; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -54,9 +80,6 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>Memby admin</h1>
|
<h1>Memby admin</h1>
|
||||||
<span id="live" class="pill muted">connecting…</span>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div id="error" class="banner"></div>
|
<div id="error" class="banner"></div>
|
||||||
@@ -71,6 +94,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Maintenance</h2>
|
<h2>Maintenance</h2>
|
||||||
<p class="muted" style="margin-top:0">
|
<p class="muted" style="margin-top:0">
|
||||||
@@ -147,17 +181,29 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<script>
|
<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) {
|
function showError(message) {
|
||||||
const banner = document.getElementById('error');
|
const banner = document.getElementById('error');
|
||||||
banner.textContent = message || '';
|
banner.textContent = message || '';
|
||||||
@@ -168,7 +214,6 @@ async function api(path, options = {}) {
|
|||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + (localStorage.getItem('memby_admin_token') || ''),
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
@@ -193,6 +238,9 @@ function duration(ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
|
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
|
||||||
|
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||||
|
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||||
|
}[char]));
|
||||||
|
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
const byType = status.library.byType || {};
|
const byType = status.library.byType || {};
|
||||||
@@ -211,6 +259,20 @@ function renderStatus(status) {
|
|||||||
? 'Import running…'
|
? 'Import running…'
|
||||||
: 'Automatic incremental import every ' + status.syncEvery + '.';
|
: '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 maintenance = status.maintenance || {};
|
||||||
const state = document.getElementById('maintenance-state');
|
const state = document.getElementById('maintenance-state');
|
||||||
state.textContent = maintenance.enabled ? 'OFFLINE' : 'online';
|
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' }) }));
|
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', () => {
|
document.getElementById('maintenance-on').addEventListener('click', () => {
|
||||||
if (!confirm('Take Memby offline for every TV?')) return;
|
if (!confirm('Take Memby offline for every TV?')) return;
|
||||||
act(() => api('/admin/api/maintenance', {
|
act(() => api('/admin/api/maintenance', {
|
||||||
@@ -346,8 +426,97 @@ document.getElementById('update-disable').addEventListener('click', () =>
|
|||||||
|
|
||||||
document.getElementById('days').addEventListener('change', refresh);
|
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();
|
refresh();
|
||||||
setInterval(refresh, 5000);
|
setInterval(refresh, 5000);
|
||||||
|
pollEvents();
|
||||||
|
setInterval(pollEvents, 5 * 60 * 1000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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) {
|
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
|
||||||
server := testServer(config.Config{})
|
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) {
|
func TestToRowEventValidatesAndClamps(t *testing.T) {
|
||||||
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
|
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/cache"
|
||||||
"github.com/ponzischeme89/memby/server/internal/config"
|
"github.com/ponzischeme89/memby/server/internal/config"
|
||||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
"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/recommend"
|
||||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
@@ -35,9 +37,11 @@ type Server struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
recommender *recommend.Engine
|
recommender *recommend.Engine
|
||||||
|
forYou *foryou.Service
|
||||||
sonarr *sonarr.Client
|
sonarr *sonarr.Client
|
||||||
syncer syncerHandle
|
syncer syncerHandle
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
events *serverlogging.Buffer
|
||||||
sonarrMu sync.Mutex
|
sonarrMu sync.Mutex
|
||||||
|
|
||||||
recommendationBuilds recommendationBuilds
|
recommendationBuilds recommendationBuilds
|
||||||
@@ -52,9 +56,11 @@ type Deps struct {
|
|||||||
Store *store.Store
|
Store *store.Store
|
||||||
Cache *cache.Cache
|
Cache *cache.Cache
|
||||||
Recommender *recommend.Engine
|
Recommender *recommend.Engine
|
||||||
|
ForYou *foryou.Service
|
||||||
Sonarr *sonarr.Client
|
Sonarr *sonarr.Client
|
||||||
Syncer syncerHandle
|
Syncer syncerHandle
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
Events *serverlogging.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, deps Deps) *Server {
|
func New(cfg config.Config, deps Deps) *Server {
|
||||||
@@ -67,9 +73,11 @@ func New(cfg config.Config, deps Deps) *Server {
|
|||||||
store: deps.Store,
|
store: deps.Store,
|
||||||
cache: deps.Cache,
|
cache: deps.Cache,
|
||||||
recommender: deps.Recommender,
|
recommender: deps.Recommender,
|
||||||
|
forYou: deps.ForYou,
|
||||||
sonarr: deps.Sonarr,
|
sonarr: deps.Sonarr,
|
||||||
syncer: deps.Syncer,
|
syncer: deps.Syncer,
|
||||||
log: deps.Log,
|
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/home", s.authed(s.handleHome))
|
||||||
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||||||
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
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/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/update", s.authed(s.handleUpdate))
|
||||||
|
|
||||||
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
|
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}/favorite", s.authed(s.handleFavorite))
|
||||||
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
|
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}/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("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||||||
|
|
||||||
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
|
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")
|
writeError(w, http.StatusInternalServerError, "session lookup failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
sess = s.captureClientIdentity(r, sess)
|
||||||
h(w, 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 {
|
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
next.ServeHTTP(rec, r)
|
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.
|
// Path only: query strings can carry image tokens.
|
||||||
level := requestLogLevel(r.URL.Path, rec.status)
|
level := requestLogLevel(r.URL.Path, rec.status)
|
||||||
s.log.Log(r.Context(), level, "HTTP request",
|
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,
|
"path", r.URL.Path,
|
||||||
"status", rec.status,
|
"status", rec.status,
|
||||||
"duration", time.Since(start).Round(time.Millisecond),
|
"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
|
// Successful high-frequency probes and artwork fetches stay available at DEBUG without
|
||||||
// overwhelming the normal Docker log. Failures are always promoted so they remain
|
// overwhelming the normal Docker log. Failures are always promoted so they remain
|
||||||
// visible regardless of path.
|
// visible regardless of path.
|
||||||
@@ -211,12 +278,14 @@ func newToken() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cachedSession struct {
|
type cachedSession struct {
|
||||||
EmbyUserID string `json:"u"`
|
EmbyUserID string `json:"u"`
|
||||||
EmbyToken string `json:"t"`
|
EmbyToken string `json:"t"`
|
||||||
Username string `json:"n"`
|
Username string `json:"n"`
|
||||||
ServerID string `json:"s"`
|
ServerID string `json:"s"`
|
||||||
DeviceID string `json:"d"`
|
DeviceID string `json:"d"`
|
||||||
DeviceName string `json:"dn,omitempty"`
|
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.
|
// 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
|
var cs cachedSession
|
||||||
if json.Unmarshal(raw, &cs) == nil {
|
if json.Unmarshal(raw, &cs) == nil {
|
||||||
return store.Session{
|
return store.Session{
|
||||||
TokenHash: hash,
|
TokenHash: hash,
|
||||||
EmbyUserID: cs.EmbyUserID,
|
EmbyUserID: cs.EmbyUserID,
|
||||||
EmbyToken: cs.EmbyToken,
|
EmbyToken: cs.EmbyToken,
|
||||||
Username: cs.Username,
|
Username: cs.Username,
|
||||||
ServerID: cs.ServerID,
|
ServerID: cs.ServerID,
|
||||||
DeviceID: cs.DeviceID,
|
DeviceID: cs.DeviceID,
|
||||||
DeviceName: cs.DeviceName,
|
DeviceName: cs.DeviceName,
|
||||||
|
ClientVersion: cs.ClientVersion,
|
||||||
|
ClientProtocol: cs.ClientProtocol,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,16 +319,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
|
|||||||
return store.Session{}, store.ErrNotFound
|
return store.Session{}, store.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if raw, err := json.Marshal(cachedSession{
|
s.cacheSession(ctx, sess)
|
||||||
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)
|
|
||||||
}
|
|
||||||
// Best-effort activity stamp; a failure here must not fail the request.
|
// Best-effort activity stamp; a failure here must not fail the request.
|
||||||
if err := s.store.Touch(ctx, hash); err != nil {
|
if err := s.store.Touch(ctx, hash); err != nil {
|
||||||
s.log.Warn("touch session failed", "error", err)
|
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
|
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 {
|
func credentials(sess store.Session) emby.Credentials {
|
||||||
return emby.Credentials{
|
return emby.Credentials{
|
||||||
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
|
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/config"
|
"github.com/ponzischeme89/memby/server/internal/config"
|
||||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBearerTokenSources(t *testing.T) {
|
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
|
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the
|
||||||
// client's non-null List fields.
|
// client's non-null List fields.
|
||||||
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
|
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.
|
// card shapes and uses ids as Compose keys.
|
||||||
func TestBaseRowsShape(t *testing.T) {
|
func TestBaseRowsShape(t *testing.T) {
|
||||||
rows := baseRows(homeResponse{
|
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) {
|
func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) {
|
||||||
var builds recommendationBuilds
|
var builds recommendationBuilds
|
||||||
|
|
||||||
@@ -200,3 +289,32 @@ func TestSummariseReadsResumePosition(t *testing.T) {
|
|||||||
t.Fatalf("resume position = %d ms, want 3600000", got)
|
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{
|
sess := store.Session{
|
||||||
TokenHash: hashToken(token),
|
TokenHash: hashToken(token),
|
||||||
EmbyUserID: auth.User.ID,
|
EmbyUserID: auth.User.ID,
|
||||||
EmbyToken: auth.AccessToken,
|
EmbyToken: auth.AccessToken,
|
||||||
Username: auth.User.Name,
|
Username: auth.User.Name,
|
||||||
ServerID: auth.ServerID,
|
ServerID: auth.ServerID,
|
||||||
DeviceID: req.DeviceID,
|
DeviceID: req.DeviceID,
|
||||||
DeviceName: req.DeviceName,
|
DeviceName: req.DeviceName,
|
||||||
|
ClientVersion: clientVersion(r),
|
||||||
|
ClientProtocol: clientProtocol(r),
|
||||||
}
|
}
|
||||||
if sess.Username == "" {
|
if sess.Username == "" {
|
||||||
sess.Username = req.Username
|
sess.Username = req.Username
|
||||||
@@ -132,6 +134,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(replacedHash) > 0 {
|
if len(replacedHash) > 0 {
|
||||||
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
|
_ = 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{
|
writeJSON(w, http.StatusOK, loginResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|||||||
+154
-24
@@ -1,12 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||||
@@ -19,9 +22,8 @@ import (
|
|||||||
// single biggest lever on home-screen latency, so keep these tight.
|
// single biggest lever on home-screen latency, so keep these tight.
|
||||||
const (
|
const (
|
||||||
fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
|
fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
|
||||||
fieldsNextUp = "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
|
|
||||||
fieldsRow = "ProductionYear,RunTimeTicks,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"
|
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
||||||
|
|
||||||
rowImageTypes = "Backdrop,Primary,Logo"
|
rowImageTypes = "Backdrop,Primary,Logo"
|
||||||
@@ -34,7 +36,7 @@ type homeResponse struct {
|
|||||||
// without touching the TV app. The client renders whatever arrives.
|
// without touching the TV app. The client renders whatever arrives.
|
||||||
Rows []recommend.Row `json:"rows"`
|
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.
|
// instant cold start, and what the direct-to-Emby path still produces.
|
||||||
ContinueWatching []json.RawMessage `json:"continueWatching"`
|
ContinueWatching []json.RawMessage `json:"continueWatching"`
|
||||||
NextUp []json.RawMessage `json:"nextUp"`
|
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)
|
cred := credentials(sess)
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
failures int
|
failures int
|
||||||
out homeResponse
|
out homeResponse
|
||||||
sonarrRow *recommend.Row
|
sonarrRow *recommend.Row
|
||||||
wg sync.WaitGroup
|
forYouRow *recommend.Row
|
||||||
|
forYouRowStale bool
|
||||||
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
|
||||||
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
|
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) {
|
run(&out.ContinueWatching, func() (*emby.ItemsResult, error) {
|
||||||
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
|
||||||
"Filters": {"IsResumable"},
|
"Recursive": {"true"},
|
||||||
"IncludeItemTypes": {"Movie,Episode"},
|
"MediaTypes": {"Video"},
|
||||||
"Recursive": {"true"},
|
"Limit": {itoa(limit)},
|
||||||
"SortBy": {"DatePlayed"},
|
|
||||||
"SortOrder": {"Descending"},
|
|
||||||
"Limit": {itoa(limit)},
|
|
||||||
}, fieldsContinue))
|
}, 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) {
|
run(&out.Favorites, func() (*emby.ItemsResult, error) {
|
||||||
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
||||||
"Filters": {"IsFavorite"},
|
"Filters": {"IsFavorite"},
|
||||||
@@ -112,6 +108,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
"Limit": {itoa(limit)},
|
"Limit": {itoa(limit)},
|
||||||
}, fieldsRow))
|
}, 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) {
|
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
|
||||||
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
||||||
"IncludeItemTypes": {"Movie"},
|
"IncludeItemTypes": {"Movie"},
|
||||||
@@ -135,6 +136,48 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
mu.Unlock()
|
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()
|
wg.Wait()
|
||||||
|
|
||||||
@@ -153,9 +196,21 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
s.refreshRecommendationsInBackground(sess)
|
s.refreshRecommendationsInBackground(sess)
|
||||||
}
|
}
|
||||||
rows := baseRows(out)
|
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 {
|
if sonarrRow != nil {
|
||||||
// The schedule is most useful beside Next Up, before personal collections.
|
nearContinue = append(nearContinue, *sonarrRow)
|
||||||
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
|
}
|
||||||
|
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...)
|
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)
|
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
|
// 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
|
// 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: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching},
|
||||||
{ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp},
|
{ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp},
|
||||||
{ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites},
|
{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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"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)
|
params.Set(key, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if writeNotModifiedForTag(w, r, params.Get("tag")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params)
|
resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,14 +68,14 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
|
|||||||
// stay conservative.
|
// stay conservative.
|
||||||
if params.Get("tag") != "" {
|
if params.Get("tag") != "" {
|
||||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||||
|
w.Header().Set("ETag", imageETag(params.Get("tag")))
|
||||||
} else {
|
} else {
|
||||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
copyImage(w, r, resp.Body, s.log,
|
||||||
s.log.Warn("image copy failed", "error", err)
|
"source", "emby", "item_id", itemID, "image_type", imageType)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
|
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.Header().Set("Cache-Control", "private, max-age=3600")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
copyImage(w, r, resp.Body, s.log,
|
||||||
s.log.Warn("sonarr image copy failed", "error", err)
|
"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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type seriesEpisodesResponse struct {
|
||||||
|
Items []json.RawMessage `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
type flagRequest struct {
|
type flagRequest struct {
|
||||||
Value bool `json:"value"`
|
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")
|
writeError(w, http.StatusBadRequest, "item id is required")
|
||||||
return
|
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 {
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||||
w.Header().Set("X-Memby-Cache", "hit")
|
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)
|
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.
|
// 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.
|
// 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) {
|
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 {
|
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
|
||||||
s.log.Warn("cache invalidation failed", "error", err)
|
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)
|
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.
|
// 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
|
// It sits outside maintenanceGate so maintenance can interrupt playback rather than only
|
||||||
// being discovered the next time a content request happens to run.
|
// 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()
|
state := s.maintenance.get()
|
||||||
message := state.Message
|
message := state.Message
|
||||||
if state.Enabled && message == "" {
|
if state.Enabled && message == "" {
|
||||||
message = store.DefaultMaintenanceMessage
|
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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"maintenance": state.Enabled,
|
"maintenance": state.Enabled,
|
||||||
"message": message,
|
"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
|
const ticksPerMillisecond = 10_000
|
||||||
|
|
||||||
type playbackResponse struct {
|
type playbackResponse struct {
|
||||||
ItemID string `json:"itemId"`
|
ItemID string `json:"itemId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ResumePositionMs int64 `json:"resumePositionMs"`
|
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 {
|
type playbackReport struct {
|
||||||
ItemID string `json:"itemId"`
|
ItemID string `json:"itemId"`
|
||||||
PositionMs int64 `json:"positionMs"`
|
PositionMs int64 `json:"positionMs"`
|
||||||
IsPaused bool `json:"isPaused"`
|
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.
|
// 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{
|
writeJSON(w, http.StatusOK, playbackResponse{
|
||||||
ItemID: target.ID,
|
ItemID: target.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
URL: s.emby.StreamURL(cred, target.ID),
|
URL: streamURL,
|
||||||
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
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
|
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
|
// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache
|
||||||
// so Continue Watching reflects the new position on the next home load.
|
// 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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID,
|
err := s.emby.ReportPlayback(
|
||||||
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused)
|
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 {
|
if err != nil {
|
||||||
// A dropped progress report is not worth failing playback over; log and accept.
|
// A dropped progress report is not worth failing playback over; log and accept.
|
||||||
s.log.Warn("playback report failed", "phase", phase, "error", err)
|
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 {
|
if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil {
|
||||||
s.log.Warn("recommendation invalidation failed", "error", err)
|
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)
|
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)})
|
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 {
|
func nonNilRows(rows []recommend.Row) []recommend.Row {
|
||||||
if rows == nil {
|
if rows == nil {
|
||||||
return []recommend.Row{}
|
return []recommend.Row{}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,6 +12,11 @@ import (
|
|||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"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
|
// 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.
|
// database round trip per home load to answer "nothing to say" would be wasteful.
|
||||||
type updatePolicyCache struct {
|
type updatePolicyCache struct {
|
||||||
@@ -63,6 +69,22 @@ func clientVersion(r *http.Request) string {
|
|||||||
return strings.TrimSpace(r.Header.Get("X-Memby-Version"))
|
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.
|
// handleUpdate answers the client's version check.
|
||||||
//
|
//
|
||||||
// Its own endpoint rather than a field on /v1/home: the home payload is cached per user,
|
// 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
|
// 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
|
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
|
||||||
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
|
// — 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 {
|
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
|
||||||
return c.Delete(ctx, RecommendationsKey(userID))
|
return c.Delete(ctx, RecommendationsKey(userID))
|
||||||
|
|||||||
@@ -78,6 +78,26 @@ type Config struct {
|
|||||||
SonarrAPIKey string
|
SonarrAPIKey string
|
||||||
SonarrTTL time.Duration
|
SonarrTTL time.Duration
|
||||||
SonarrLocation *time.Location
|
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) {
|
func Load() (Config, error) {
|
||||||
@@ -104,16 +124,24 @@ func Load() (Config, error) {
|
|||||||
ReleasePublishToken: strings.TrimSpace(
|
ReleasePublishToken: strings.TrimSpace(
|
||||||
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
|
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
|
||||||
),
|
),
|
||||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||||
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
|
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
|
||||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||||
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
||||||
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
|
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
|
||||||
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
|
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 == "" {
|
if c.EmbyURL == "" {
|
||||||
@@ -134,6 +162,9 @@ func Load() (Config, error) {
|
|||||||
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
|
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
|
||||||
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
|
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"))
|
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
|
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ type AuthResult struct {
|
|||||||
ServerID string `json:"ServerId"`
|
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.
|
// Summary is the minimal view of an item the gateway needs for its own logic.
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
ID string `json:"Id"`
|
ID string `json:"Id"`
|
||||||
@@ -57,6 +65,35 @@ type Summary struct {
|
|||||||
} `json:"UserData"`
|
} `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.
|
// APIError carries an upstream Emby status code so handlers can mirror it.
|
||||||
type APIError struct {
|
type APIError struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
@@ -115,6 +152,20 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error {
|
|||||||
return c.do(req, nil)
|
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) {
|
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)
|
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
|
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.
|
// SetFavorite/SetPlayed return Emby's resulting UserData verbatim.
|
||||||
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
|
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
|
||||||
method := http.MethodDelete
|
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".
|
// 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
|
var path string
|
||||||
switch phase {
|
switch phase {
|
||||||
case "started":
|
case "started":
|
||||||
@@ -202,12 +346,22 @@ func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase str
|
|||||||
|
|
||||||
body, err := json.Marshal(map[string]any{
|
body, err := json.Marshal(map[string]any{
|
||||||
"ItemId": itemID,
|
"ItemId": itemID,
|
||||||
|
"MediaSourceId": mediaSourceID,
|
||||||
|
"PlaySessionId": playSessionID,
|
||||||
"PositionTicks": positionTicks,
|
"PositionTicks": positionTicks,
|
||||||
"IsPaused": isPaused,
|
"IsPaused": isPaused,
|
||||||
"IsMuted": false,
|
"IsMuted": false,
|
||||||
"CanSeek": true,
|
"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 {
|
if err != nil {
|
||||||
return err
|
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())
|
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.
|
// Ping checks that Emby is reachable, for readiness probes.
|
||||||
func (c *Client) Ping(ctx context.Context) error {
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
|
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
|
||||||
|
|||||||
@@ -56,14 +56,22 @@ type Syncer struct {
|
|||||||
// is borrowed instead.
|
// is borrowed instead.
|
||||||
serviceCred emby.Credentials
|
serviceCred emby.Credentials
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
|
afterSync func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
|
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}
|
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.
|
// Result summarises one import.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Kind string `json:"kind"`
|
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,
|
"kind", kind, "trigger", trigger, "seen", result.Seen,
|
||||||
"upserted", result.Upserted, "removed", result.Removed,
|
"upserted", result.Upserted, "removed", result.Removed,
|
||||||
"duration", result.Duration.Round(time.Millisecond))
|
"duration", result.Duration.Round(time.Millisecond))
|
||||||
|
s.mu.Lock()
|
||||||
|
afterSync := s.afterSync
|
||||||
|
s.mu.Unlock()
|
||||||
|
if afterSync != nil {
|
||||||
|
afterSync()
|
||||||
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,5 +359,12 @@ func searchText(parsed syncItem) string {
|
|||||||
parts = append(parts, strconv.Itoa(*parsed.ProductionYear))
|
parts = append(parts, strconv.Itoa(*parsed.ProductionYear))
|
||||||
}
|
}
|
||||||
parts = append(parts, parsed.Genres...)
|
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, " ")
|
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) {
|
func TestSearchTextDoesNotRepeatTheTitleForAMovie(t *testing.T) {
|
||||||
text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"})
|
text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"})
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Row is one horizontal strip on the TV home screen.
|
// 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)
|
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
|
// LibrarySource is the imported catalogue. When present, the candidate pool comes from
|
||||||
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
|
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
|
||||||
type LibrarySource interface {
|
type LibrarySource interface {
|
||||||
@@ -44,15 +54,29 @@ type CuratedLibrarySource interface {
|
|||||||
) ([]json.RawMessage, error)
|
) ([]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;
|
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
|
||||||
// the user's profile determines both item order and shelf order.
|
// the user's profile determines both item order and shelf order.
|
||||||
type CuratedRow struct {
|
type CuratedRow struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Kind string
|
Kind string
|
||||||
ItemTypes []string
|
ItemTypes []string
|
||||||
Genres []string
|
Genres []string
|
||||||
Studios []string
|
Studios []string
|
||||||
|
RequireAffinity bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
@@ -60,7 +84,9 @@ type Engine struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
// Library is optional; nil (or an empty library) falls back to querying Emby.
|
// 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
|
// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
|
||||||
// looks broken next to full rows, so short rows are dropped entirely.
|
// looks broken next to full rows, so short rows are dropped entirely.
|
||||||
@@ -72,6 +98,333 @@ type Engine struct {
|
|||||||
CuratedRows []CuratedRow
|
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 {
|
func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||||
return &Engine{
|
return &Engine{
|
||||||
source: source,
|
source: source,
|
||||||
@@ -89,22 +442,81 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "curated:drama-shows",
|
ID: "curated:drama-shows",
|
||||||
Title: "Drama TV Shows",
|
Title: "Drama Shows",
|
||||||
Kind: "shows",
|
Kind: "shows",
|
||||||
ItemTypes: []string{"Series"},
|
ItemTypes: []string{"Series"},
|
||||||
Genres: []string{"Drama"},
|
Genres: []string{"Drama"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "curated:comedy-shows",
|
ID: "curated:comedy-shows",
|
||||||
Title: "Comedy TV Shows",
|
Title: "Comedy Shows",
|
||||||
Kind: "shows",
|
Kind: "shows",
|
||||||
ItemTypes: []string{"Series"},
|
ItemTypes: []string{"Series"},
|
||||||
Genres: []string{"Comedy"},
|
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 (
|
const (
|
||||||
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
|
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
|
||||||
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
|
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))
|
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
|
||||||
for order, definition := range 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,
|
definition: definition,
|
||||||
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
|
affinity: affinity,
|
||||||
order: order,
|
order: order,
|
||||||
})
|
}
|
||||||
|
if definition.RequireAffinity && ranked.affinity <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
definitions = append(definitions, ranked)
|
||||||
}
|
}
|
||||||
sort.SliceStable(definitions, func(i, j int) bool {
|
sort.SliceStable(definitions, func(i, j int) bool {
|
||||||
if definitions[i].affinity != definitions[j].affinity {
|
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))
|
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 {
|
for _, ranked := range definitions {
|
||||||
definition := ranked.definition
|
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(
|
raws, err := library.CuratedCandidates(
|
||||||
ctx,
|
ctx,
|
||||||
definition.ItemTypes,
|
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)
|
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
|
||||||
continue
|
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 {
|
if len(items) < e.MinRowItems {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
seenItems[item.ID] = struct{}{}
|
||||||
|
}
|
||||||
rows = append(rows, Row{
|
rows = append(rows, Row{
|
||||||
ID: definition.ID,
|
ID: definition.ID,
|
||||||
Title: definition.Title,
|
Title: definition.Title,
|
||||||
Kind: definition.Kind,
|
Kind: definition.Kind,
|
||||||
Items: Raws(items),
|
Items: Raws(items),
|
||||||
})
|
})
|
||||||
|
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
|
||||||
|
movieGenreRows++
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
|
||||||
|
movieStudioRows++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
@@ -227,18 +684,20 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
|
|||||||
"Recursive": {"true"},
|
"Recursive": {"true"},
|
||||||
"SortBy": {"DatePlayed"},
|
"SortBy": {"DatePlayed"},
|
||||||
"SortOrder": {"Descending"},
|
"SortOrder": {"Descending"},
|
||||||
"Limit": {"20"},
|
// Imported catalogue rows have no user state, so this query is also the
|
||||||
"Fields": {historyFields},
|
// exclusion set. Household libraries are small enough to fetch it completely.
|
||||||
"EnableUserData": {"true"},
|
"Limit": {"5000"},
|
||||||
"EnableImages": {"false"},
|
"Fields": {historyFields},
|
||||||
|
"EnableUserData": {"true"},
|
||||||
|
"EnableImages": {"false"},
|
||||||
})
|
})
|
||||||
fetch(&played, url.Values{
|
fetch(&played, url.Values{
|
||||||
"Filters": {"IsPlayed"},
|
"Filters": {"IsPlayed"},
|
||||||
"IncludeItemTypes": {"Movie,Episode"},
|
"IncludeItemTypes": {"Movie,Episode,Series"},
|
||||||
"Recursive": {"true"},
|
"Recursive": {"true"},
|
||||||
"SortBy": {"DatePlayed"},
|
"SortBy": {"DatePlayed"},
|
||||||
"SortOrder": {"Descending"},
|
"SortOrder": {"Descending"},
|
||||||
"Limit": {"60"},
|
"Limit": {"5000"},
|
||||||
"Fields": {historyFields},
|
"Fields": {historyFields},
|
||||||
"EnableUserData": {"true"},
|
"EnableUserData": {"true"},
|
||||||
"EnableImages": {"false"},
|
"EnableImages": {"false"},
|
||||||
@@ -261,6 +720,28 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
|
|||||||
return append(resumable, played...), favorites, nil
|
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
|
// 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".
|
// 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) {
|
func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
"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.
|
// fakeSource records the queries the engine makes and replays canned answers.
|
||||||
@@ -22,6 +25,8 @@ type fakeSource struct {
|
|||||||
similar map[string][]json.RawMessage
|
similar map[string][]json.RawMessage
|
||||||
itemsErr error
|
itemsErr error
|
||||||
similarErr error
|
similarErr error
|
||||||
|
nextUp []json.RawMessage
|
||||||
|
nextUpErr error
|
||||||
|
|
||||||
genreQueries []string
|
genreQueries []string
|
||||||
similarSeeds []string
|
similarSeeds []string
|
||||||
@@ -31,6 +36,36 @@ type fakeCuratedLibrary struct {
|
|||||||
byGenre map[string][]json.RawMessage
|
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(
|
func (f *fakeCuratedLibrary) LibraryCandidates(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_ []string,
|
_ []string,
|
||||||
@@ -62,7 +97,11 @@ func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Val
|
|||||||
f.genreQueries = append(f.genreQueries, genres)
|
f.genreQueries = append(f.genreQueries, genres)
|
||||||
}
|
}
|
||||||
key := params.Get("Filters")
|
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) {
|
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
|
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 {
|
func raw(id, name, itemType string, genres ...string) json.RawMessage {
|
||||||
quoted := make([]string, 0, len(genres))
|
quoted := make([]string, 0, len(genres))
|
||||||
for _, g := range 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) {
|
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
|
||||||
source := &fakeSource{
|
source := &fakeSource{
|
||||||
itemsByFilter: map[string][]json.RawMessage{
|
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) {
|
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
|
||||||
source := &fakeSource{
|
source := &fakeSource{
|
||||||
itemsByFilter: map[string][]json.RawMessage{
|
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) {
|
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
|
||||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||||
engine := testEngine(source)
|
engine := testEngine(source)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
|
// 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
|
// 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.
|
// along so rows can be emitted without re-fetching or re-encoding.
|
||||||
type Item struct {
|
type Item struct {
|
||||||
ID string `json:"Id"`
|
ID string `json:"Id"`
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
Type string `json:"Type"`
|
Type string `json:"Type"`
|
||||||
SeriesID string `json:"SeriesId"`
|
SeriesID string `json:"SeriesId"`
|
||||||
SeriesName string `json:"SeriesName"`
|
SeriesName string `json:"SeriesName"`
|
||||||
Genres []string `json:"Genres"`
|
ProductionYear int `json:"ProductionYear"`
|
||||||
CommunityRating float64 `json:"CommunityRating"`
|
Genres []string `json:"Genres"`
|
||||||
Studios []struct {
|
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"`
|
Name string `json:"Name"`
|
||||||
} `json:"Studios"`
|
} `json:"Studios"`
|
||||||
UserData struct {
|
UserData struct {
|
||||||
@@ -46,6 +57,38 @@ type Item struct {
|
|||||||
Raw json.RawMessage `json:"-"`
|
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.
|
// Seed is a title recent enough to anchor a "Because you watched …" row.
|
||||||
type Seed struct {
|
type Seed struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -58,8 +101,9 @@ type Profile struct {
|
|||||||
StudioWeights map[string]float64
|
StudioWeights map[string]float64
|
||||||
// Seen holds item ids *and* series ids already watched or in progress, so a
|
// Seen holds item ids *and* series ids already watched or in progress, so a
|
||||||
// recommendation never suggests something the user is already partway through.
|
// recommendation never suggests something the user is already partway through.
|
||||||
Seen map[string]bool
|
Seen map[string]bool
|
||||||
Seeds []Seed
|
SeenTitles map[string]bool
|
||||||
|
Seeds []Seed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
|
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{},
|
GenreWeights: map[string]float64{},
|
||||||
StudioWeights: map[string]float64{},
|
StudioWeights: map[string]float64{},
|
||||||
Seen: map[string]bool{},
|
Seen: map[string]bool{},
|
||||||
|
SeenTitles: map[string]bool{},
|
||||||
}
|
}
|
||||||
|
|
||||||
seedSeen := map[string]bool{}
|
seedSeen := map[string]bool{}
|
||||||
|
tasteSeen := map[string]bool{}
|
||||||
for i, item := range history {
|
for i, item := range history {
|
||||||
weight := math.Pow(recencyDecay, float64(i))
|
profile.markSeen(item)
|
||||||
profile.absorb(item, weight)
|
|
||||||
|
// 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"
|
// An episode seeds its series, not itself: "Because you watched Severance"
|
||||||
// reads better than "Because you watched Good News".
|
// 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) {
|
func (p *Profile) absorb(item Item, weight float64) {
|
||||||
|
p.markSeen(item)
|
||||||
|
p.absorbTaste(item, weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Profile) markSeen(item Item) {
|
||||||
if item.ID != "" {
|
if item.ID != "" {
|
||||||
p.Seen[item.ID] = true
|
p.Seen[item.ID] = true
|
||||||
}
|
}
|
||||||
if item.SeriesID != "" {
|
if item.SeriesID != "" {
|
||||||
p.Seen[item.SeriesID] = true
|
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 {
|
for _, genre := range item.Genres {
|
||||||
if g := strings.TrimSpace(genre); g != "" {
|
if g := strings.TrimSpace(genre); g != "" {
|
||||||
p.GenreWeights[g] += weight
|
p.GenreWeights[g] += weight
|
||||||
@@ -168,6 +238,9 @@ func (p Profile) Score(candidate Item) float64 {
|
|||||||
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
|
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
if p.SeenTitles[candidate.SeenKey()] {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
|
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
|
||||||
return -1
|
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) {
|
func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
|
||||||
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
|
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
|
||||||
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})
|
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})
|
||||||
|
|||||||
@@ -2,12 +2,51 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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.
|
// RowEvent is one reported interaction with a home-screen row.
|
||||||
type RowEvent struct {
|
type RowEvent struct {
|
||||||
OccurredAt time.Time
|
OccurredAt time.Time
|
||||||
|
|||||||
@@ -138,6 +138,21 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
|
|||||||
return collectPayloads(rows)
|
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
|
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
|
||||||
// are matched case-insensitively because Emby studio capitalisation is not consistent.
|
// are matched case-insensitively because Emby studio capitalisation is not consistent.
|
||||||
func (s *Store) CuratedCandidates(
|
func (s *Store) CuratedCandidates(
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
server_id TEXT NOT NULL DEFAULT '',
|
server_id TEXT NOT NULL DEFAULT '',
|
||||||
device_id TEXT NOT NULL DEFAULT '',
|
device_id TEXT NOT NULL DEFAULT '',
|
||||||
device_name TEXT NOT NULL DEFAULT 'Memby TV',
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
last_seen_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 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
|
-- Older builds could create more than one token for the same physical TV. Keep the most
|
||||||
-- recently used row before adding the identity constraint.
|
-- 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_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);
|
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) {
|
func (s *Store) NewestSession(ctx context.Context) (Session, error) {
|
||||||
var sess Session
|
var sess Session
|
||||||
err := s.pool.QueryRow(ctx, `
|
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`).
|
FROM sessions ORDER BY last_seen_at DESC LIMIT 1`).
|
||||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ var ErrNotFound = errors.New("store: session not found")
|
|||||||
var ErrDeviceLimit = errors.New("store: device limit reached")
|
var ErrDeviceLimit = errors.New("store: device limit reached")
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
TokenHash []byte
|
TokenHash []byte
|
||||||
EmbyUserID string
|
EmbyUserID string
|
||||||
EmbyToken string
|
EmbyToken string
|
||||||
Username string
|
Username string
|
||||||
ServerID string
|
ServerID string
|
||||||
DeviceID string
|
DeviceID string
|
||||||
DeviceName string
|
DeviceName string
|
||||||
LastSeenAt time.Time
|
ClientVersion string
|
||||||
|
ClientProtocol string
|
||||||
|
LastSeenAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store struct {
|
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) }
|
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.
|
// Migrate applies the schema. It is idempotent, so it runs on every boot.
|
||||||
func (s *Store) Migrate(ctx context.Context) error {
|
func (s *Store) Migrate(ctx context.Context) error {
|
||||||
if _, err := s.pool.Exec(ctx, schema); err != nil {
|
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, `
|
_, err = tx.Exec(ctx, `
|
||||||
INSERT INTO sessions (
|
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
|
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
|
||||||
token_hash = EXCLUDED.token_hash,
|
token_hash = EXCLUDED.token_hash,
|
||||||
emby_token = EXCLUDED.emby_token,
|
emby_token = EXCLUDED.emby_token,
|
||||||
username = EXCLUDED.username,
|
username = EXCLUDED.username,
|
||||||
server_id = EXCLUDED.server_id,
|
server_id = EXCLUDED.server_id,
|
||||||
device_name = EXCLUDED.device_name,
|
device_name = EXCLUDED.device_name,
|
||||||
|
client_version = EXCLUDED.client_version,
|
||||||
|
client_protocol = EXCLUDED.client_protocol,
|
||||||
last_seen_at = now()`,
|
last_seen_at = now()`,
|
||||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
|
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 {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("store: create session: %w", err)
|
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) {
|
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
|
||||||
var sess Session
|
var sess Session
|
||||||
err := s.pool.QueryRow(ctx, `
|
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).
|
FROM sessions WHERE token_hash = $1`, hash).
|
||||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -141,6 +200,23 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error {
|
|||||||
return err
|
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 {
|
func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
|
||||||
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash)
|
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash)
|
||||||
return err
|
return err
|
||||||
@@ -176,10 +252,11 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
|
|||||||
AND ranked.device_rank > $1
|
AND ranked.device_rank > $1
|
||||||
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
|
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
|
||||||
current.username, current.server_id, current.device_id,
|
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,
|
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`,
|
FROM retired`,
|
||||||
maxClients,
|
maxClients,
|
||||||
)
|
)
|
||||||
@@ -193,7 +270,8 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
|
|||||||
var sess Session
|
var sess Session
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
&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 {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
|
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user