From 70914400b479df88ed50cca9b630d596265fd635 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 29 Jul 2026 15:26:27 +1200 Subject: [PATCH] Big changes --- .env.example | 35 +- CLAUDE.md | 119 +- README.md | 12 + app/build.gradle.kts | 32 +- app/src/main/AndroidManifest.xml | 40 +- .../com/ponzischeme89/memby/ServiceLocator.kt | 6 +- .../memby/data/EmbyRepository.kt | 474 +++++- .../ponzischeme89/memby/data/SettingsStore.kt | 119 +- .../memby/data/model/EmbyModels.kt | 127 ++ .../memby/data/model/GatewayModels.kt | 67 + .../memby/data/remote/EmbyApi.kt | 16 + .../memby/data/remote/GatewayApi.kt | 31 + .../data/remote/GatewayServiceFactory.kt | 3 + .../ponzischeme89/memby/ui/HomeComponents.kt | 401 ++++- .../ponzischeme89/memby/ui/HomeViewModel.kt | 114 +- .../ponzischeme89/memby/ui/MainActivity.kt | 750 +++++++-- .../memby/ui/MaintenanceScreen.kt | 20 +- .../ponzischeme89/memby/ui/UpdateScreen.kt | 2 +- .../memby/ui/player/PlayerActivity.kt | 1387 ++++++++++++++++- .../memby/ui/screensaver/EmbyAppLauncher.kt | 4 +- .../ui/screensaver/ScreensaverContent.kt | 6 +- .../memby/ui/settings/SettingsSheet.kt | 939 ++++++++--- .../com/ponzischeme89/memby/ui/theme/Theme.kt | 23 +- .../memby/update/UpdateChecker.kt | 4 +- .../emby_logo.png | Bin app/src/main/res/drawable/app_banner.xml | 4 + app/src/main/res/values/strings.xml | 39 +- app/src/main/res/values/themes.xml | 2 + .../memby/data/GatewayPayloadTest.kt | 90 ++ .../memby/data/PlaybackReportMathTest.kt | 13 + .../memby/data/ProfileSettingsTest.kt | 17 + .../memby/ui/ServerHomeRowsTest.kt | 97 +- docker-compose.yml | 16 +- server/README.md | 141 +- server/cmd/memby-server/main.go | 62 +- server/internal/api/admin.go | 122 +- server/internal/api/admin.html | 195 ++- server/internal/api/admin_test.go | 75 + server/internal/api/api.go | 128 +- server/internal/api/api_test.go | 120 +- server/internal/api/auth.go | 20 +- server/internal/api/home.go | 178 ++- server/internal/api/images.go | 81 +- server/internal/api/items.go | 58 +- server/internal/api/maintenance.go | 23 +- server/internal/api/playback.go | 298 +++- server/internal/api/recommend.go | 40 + server/internal/api/update.go | 22 + server/internal/cache/cache.go | 2 +- server/internal/config/config.go | 51 +- server/internal/emby/client.go | 202 ++- server/internal/library/syncer.go | 25 +- server/internal/library/syncer_test.go | 17 + server/internal/recommend/engine.go | 519 +++++- server/internal/recommend/engine_test.go | 456 +++++- server/internal/recommend/profile.go | 97 +- server/internal/recommend/profile_test.go | 17 + server/internal/store/analytics.go | 39 + server/internal/store/library.go | 15 + server/internal/store/schema.sql | 117 ++ server/internal/store/settings.go | 6 +- server/internal/store/store.go | 110 +- 62 files changed, 7501 insertions(+), 744 deletions(-) rename app/src/main/res/{drawable => drawable-nodpi}/emby_logo.png (100%) diff --git a/.env.example b/.env.example index 67de645..5bb505d 100644 --- a/.env.example +++ b/.env.example @@ -5,14 +5,14 @@ MEMBY_EMBY_URL=https://molise.bounceme.net # What TVs are told to stream from. Only set this when it differs from the address # above — video goes device -> Emby directly, never through the gateway. -#MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net +MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net # Postgres password for the memby role. Generate one, e.g. # openssl rand -base64 24 -POSTGRES_PASSWORD=change-me +POSTGRES_PASSWORD=7dfc3eb07108013c7bea9787457397551b5eb9059adac51aea4741f4ec649be2 -# Host port the gateway listens on. -MEMBY_PORT=8080 +# Fixed NAS host port used by the mserver.sublogue.com reverse proxy. +MEMBY_PORT=32768 # INFO is recommended. DEBUG also logs successful health, status and artwork requests. MEMBY_LOG_LEVEL=INFO @@ -26,10 +26,10 @@ MEMBY_HOME_TTL=60s # Maximum number of distinct Memby TVs one Emby user may keep signed in. MEMBY_MAX_CLIENTS_PER_USER=1 -# Admin interface at http://:8080/admin/ — library imports, the maintenance -# switch, and row analytics. Leave blank to disable /admin entirely. Generate with +# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the +# maintenance switch, and row analytics. Leave blank to disable /admin entirely. Generate with # openssl rand -hex 32 -MEMBY_ADMIN_TOKEN= +MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd8c # Public gateway address and a dedicated token used only by the Gitea release workflow. # Generate the token with: openssl rand -hex 32 @@ -42,12 +42,25 @@ MEMBY_SYNC_ON_START=false # Optional Emby service account for imports. Without it the gateway borrows the most # recently active TV session, which works but stops if that user is removed. -#MEMBY_SYNC_USER_ID= -#MEMBY_SYNC_API_KEY= +MEMBY_SYNC_USER_ID=23bf90fc1fe049ce898db7adda1ed7bf +MEMBY_SYNC_API_KEY=56775917938841e7ac1b6a233d4d5075 # Optional Sonarr calendar integration. Use the URL reachable from this container, # not necessarily the address entered in a browser. The API key is in Sonarr under # Settings > General > Security. -#MEMBY_SONARR_URL=http://192.168.20.2:8989 -#MEMBY_SONARR_API_KEY= +MEMBY_SONARR_URL=http://10.0.0.2:8989 +MEMBY_SONARR_API_KEY=6b608b051ee24582925773bd5dfbe37a MEMBY_SONARR_TTL=5m +# How long after an episode airs the "aired, coming soon" banner keeps being offered. +# 0 turns the banners off and leaves the airing-today row alone. +MEMBY_SONARR_ALERT_WINDOW=3h + +# Optional Tracearr-powered For You signals. Create a read-only public API key in +# Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers. +MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/ +MEMBY_TRACEARR_API_KEY=trr_pub_WKSdiZFGZ_10d-zgQ2Wx0H4Ym4NAiIeLBSvf8a6rvZ0 +MEMBY_TRACEARR_SERVER_ID=6964b9ed-3a21-4f51-b94b-cd344ec42c1b +MEMBY_TRACEARR_SYNC_INTERVAL=5m +MEMBY_TRACEARR_FULL_INTERVAL=24h +MEMBY_FOR_YOU_MIN_REBUILD_AGE=10m +MEMBY_FOR_YOU_REFRESH_INTERVAL=30m diff --git a/CLAUDE.md b/CLAUDE.md index 13dac8c..a840ea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,29 @@ go build ./... && go test ./... # add -buildvcs=false on Windows if .git is docker compose up -d --build # from the repo root; needs .env (see .env.example) ``` +**Deploying the gateway to the NAS** is `deploy-server.ps1` (PowerShell 7): + +```powershell +.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby +.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test +``` + +It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over +one SSH connection (interactive password; stdin carries the +archive, so OpenSSH prompts on the tty). The remote half stages into +`.new.$$`, builds, then swaps directories and waits for all three health +checks, restoring the previous release if anything fails. The named Postgres volume is +preserved — it never runs `compose down -v`. + +**`.env.example` is the configuration.** It holds real values, and every deployment +overwrites the NAS's `.env` with the local copy (the old one is kept beside it as +`.env.previous`). The script requires `MEMBY_PORT=32768`, `MEMBY_ADMIN_TOKEN`, +`MEMBY_EMBY_URL` and `POSTGRES_PASSWORD` before activation, then confirms the admin token +reached the running container. The database volume is always preserved; deployment stops +before activation if the Postgres password differs from the deployed value, because a +credential change requires an explicit database migration. The local working tree is +deployed directly; no commit or push is required. + `local.properties` must contain `sdk.dir=...` when building from the CLI. Lint has `abortOnError = false` (media3's `@UnstableApi` opt-in check would otherwise fail @@ -82,6 +105,12 @@ keystore the build still succeeds but emits an unsigned APK and logs a warning. matters more than the code: Android identifies an app by applicationId **plus** signing key, so a changed key forces every user to uninstall and reinstall. +For direct TV deployment without publishing a release, `deploy-tv.ps1` builds and verifies +the signed release, connects over wireless ADB, installs it with `-r`, and launches the +Leanback activity. It reads the same signing settings from the current user's persistent +`MEMBY_KEYSTORE*` environment variables and defaults to the living-room Chromecast endpoint; +pass `-Device host:port` when Android rotates the wireless-debugging port. + `UpdateChecker` supports two sources, chosen by URL shape in `isManifestUrl` — a `.json` URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`. @@ -142,7 +171,9 @@ the container is down. Specifics worth knowing: gateway proxy, `api_key=` for Emby. - Video always direct-plays from Emby. The gateway returns a URL; it never proxies a stream. Don't route playback through it. -- Search exists on the gateway (`repository.search`) but has no UI yet. +- Search is dual-path like the rest: `/v1/search` on the gateway (Postgres full-text, + falling back to Emby before the first import), `SearchTerm` on `Users/{id}/Items` + directly. `ui/search/` renders it — see "Search" below. The wire contract is pinned from both ends: `GatewayPayloadTest.kt` / `ServerHomeRowsTest.kt` (Kotlin) and `internal/api/api_test.go` (Go). Change a field name or a row `kind` and one @@ -168,6 +199,50 @@ navigation rail stays mounted beside it so Settings and Switch user still work, retry button takes `contentFocusRequester` (with `focusProperties { left = … }` back to the rail) because otherwise D-pad focus has nowhere to go once the rows are gone. +**Service alerts.** `/v1/status` is the only thing an open app polls continuously (10s, +`MaintenanceMonitor`), so it doubles as the push channel: alongside maintenance state it +carries an `alerts` array, and `ui/ServiceAlertBanner.kt` drops one in as a full-width bar +across the top of the screen, broadcast-notice style (it spans the navigation rail too). +The only producer today is `api/alerts.go` — an episode whose Sonarr air time has passed +but which Emby has not imported yet ("aired, coming soon"). It reads the *cached* +airing-today calendar, so polling clients never cost a Sonarr request. Things to preserve: +the server has no idea which TVs saw what, so the client dedupes by id against +`SettingsStore.markAlertSeen` (persisted, or every relaunch replays yesterday's news); an +alert is only *offered* until the banner calls `alertShown` — nothing is persisted and no +dismissal timer runs before that, so one arriving behind the screensaver waits rather than +being consumed by nobody, and `pendingAlertExpired` drops it once the gateway stops +offering it. The status loop itself runs under +`ProcessLifecycleOwner … repeatOnLifecycle(STARTED)`, so a backgrounded app stops polling +entirely instead of hitting the gateway every 10s at a TV nobody is watching. The banner is +never focusable and +times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring counting it +down — take the duration from that constant, or the ring and the timer drift apart), +because stealing D-pad focus mid-browse is worse than a missed notice; +and alerts are suppressed under maintenance and under a mandatory update, which own the +screen. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row. + +**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed +6×6 on-screen keyboard on the left, a results grid on the right that updates as you type. +Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` → +`trim` → `distinctUntilChanged` → `collectLatest { repository.search(it) }` — and +`collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow +response for a prefix can never overwrite the results for what was typed after it. +Searching starts at two characters (`shouldSearch`); one letter matches half a library. +`rankSearchResults` is a pure, stable sort that only lifts exact/prefix/word-boundary +title matches above the backend's own relevance order — it never re-sorts alphabetically, +and it keeps weak matches rather than showing an empty pane. A small access-ordered map +caches results per query for the session, so backspacing is instant. + +Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the +rightmost goes to the results grid, the grid's first column goes back to the *last key +used* (a `FocusRequester` attached to whichever key that is), and the grid has a +`focusRestorer`. Back moves results → keyboard → clear query → leave, one step per press. +Physical keyboards and phone-remote apps feed the same state through one +`onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back +must fall through. The voice button needs the `android.speech.RecognitionService` entry in +the manifest's ``, or `isRecognitionAvailable` returns false on Android 11+ and +it hides itself on devices that actually support it. + **Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s, on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows @@ -234,6 +309,20 @@ a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches `media3-exoplayer-hls` dependency is already present for when that's added. Progress is reported back to Emby via `reportPlaybackStarted/Progress/Stopped`. +**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up +`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings +→ Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from +`repository.nextEpisode`, dual-path like everything else: `/v1/items/{id}/next` on the +gateway, `Shows/{seriesId}/Episodes?AdjacentTo=` directly. Both rely on Emby returning +`[previous, current, next]` in running order, so it is the *position* of the current +episode that identifies the next one — never the length of the list, which shrinks at both +ends of a season (`episodeAfter` in `playback.go`, unit-tested). Three things are easy to +break: the countdown is driven off the playhead, not a timer of its own, so pausing holds +it and seeking backwards out of the window re-arms it; advancing swaps the `MediaItem` +inside the running player instead of relaunching the activity, so `itemId`/`playbackStarted` +/`stopReported` must all be reset together or the outgoing episode is never reported +stopped; and a movie simply resolves to null, which is why nothing special-cases item type. + **Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are @@ -246,3 +335,31 @@ Material 3 ones. `MainActivity.kt`, `HomeComponents.kt` and `ScreensaverContent. three large files — new screens generally belong in `ui//` rather than growing them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`); everything must be reachable by D-pad only. + +**Animations must not recompose.** This app ships to weak TV boxes, so an animated value +read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is +a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and +read it inside a `Canvas`/`drawBehind` block (draw phase only), and derive any text from it +with `derivedStateOf` so it recomposes when the *displayed* value changes, not when the +float does. `ServiceAlertBanner`'s countdown ring and pulse are the worked example: ~10 +recompositions of one number over ten seconds instead of ~600 of the whole bar. The same +rule applies to collecting flows — collect in the smallest composable that needs the value, +not at the top of `MainActivity`, or every emission recomposes the launcher. + +**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV, +landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather +than a bare `@Preview`, which defaults to a phone and misrepresents every layout here. +A preview does not run `ServiceLocator`, so only composables that take their state as +parameters are previewable — the same property that makes them unit-testable. Prefer +previewing the still inner composable over an animated wrapper (`AlertBanner`, not +`ServiceAlertBanner`): a frozen frame of a slide-in shows nothing useful. + +**Screenshots.** `app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables +to PNGs under `app/build/screenshots/` via Roborazzi + Robolectric, at TV 1080p qualifiers +— the way to look at a layout without a TV to hand. This is the *only* Android dependency +allowed in `app/src/test`; keep it confined to `*ScreenshotTest.kt` files so logic tests +stay pure JUnit. Recording is always on (`roborazzi.test.record` in `testOptions`): these +are artifacts to look at, not checked-in goldens, and a screenshot test that silently +captures nothing is worse than none. AGP's own `com.android.compose.screenshot` plugin was +tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without +checking that upstream. diff --git a/README.md b/README.md index 245b365..d39a876 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ tracks during playback. The screensaver (Dream) code remains isolated from the c - **In-app playback** with Media3/ExoPlayer (movies play directly; a series plays its next-up / first episode). - **Favorites** managed through the Emby API and shown on the home screen. +- A dedicated **For You** destination, shaped by Tracearr completion/device history, + Memby browsing signals and a viewer-selected 30/60/120-minute time window. Every pick + says why it was chosen and whether it has direct-play evidence on that television. ## Tech stack @@ -93,6 +96,15 @@ new episodes), composes the home rows — including "Recommended from your watch history" — and has an admin page at `/admin/` for imports, an offline switch, and per-row engagement. +For Tracearr-powered recommendations, create a read-only public API key in Tracearr and +set `MEMBY_TRACEARR_URL` plus `MEMBY_TRACEARR_API_KEY` on the gateway (and optionally +`MEMBY_TRACEARR_SERVER_ID` when Tracearr monitors several servers). Tracearr credentials +remain server-side; the Android app only receives ranked Emby items and short reasons. +The gateway imports compact session signals into Postgres every five minutes and prepares +an over-provisioned per-user pool, so opening For You normally performs one indexed +database read. The live on-demand path remains available for cold starts and failed +rebuilds. + ## Server address Memby is built for one Emby server, so the address is baked into the APK instead of being diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b44247c..927ecc3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.1.54" +val defaultVersionName = "0.1.69" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() @@ -116,6 +116,21 @@ android { abortOnError = false } + testOptions { + unitTests { + // Robolectric needs the merged resources to inflate anything; only the + // screenshot tests use them. + isIncludeAndroidResources = true + + // Roborazzi writes PNGs only in record mode. These images are artifacts to + // look at, not checked-in goldens to diff against, so recording is always on + // — a screenshot test that silently captures nothing is worse than none. + all { + it.systemProperty("roborazzi.test.record", "true") + } + } + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -133,6 +148,10 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.work:work-runtime-ktx:2.10.0") + // ProcessLifecycleOwner: lets the status poll stop while no Memby screen is on top, + // instead of hitting the gateway every ten seconds for as long as the process lives. + implementation("androidx.lifecycle:lifecycle-process:2.8.7") implementation("androidx.savedstate:savedstate-ktx:1.2.1") // Measurement only: JankStats is enabled by PerformanceMonitor for debug builds. implementation("androidx.metrics:metrics-performance:1.0.0") @@ -167,4 +186,15 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") testImplementation("junit:junit:4.13.2") + + // Screenshot rendering only. Everything else under app/src/test stays plain JUnit + // with no Android on the classpath — see the note in CLAUDE.md. Rendering a + // composable is the one thing that genuinely cannot be done that way, and these are + // confined to *ScreenshotTest.kt files. + testImplementation("org.robolectric:robolectric:4.14.1") + testImplementation("androidx.test.ext:junit:1.2.1") + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.32.2") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.32.2") + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e4002dc..08713f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,30 @@ + + + + + + + + + + + + + + + + + + + + + - + @@ -43,7 +71,8 @@ android:name=".ui.screensaver.ScreensaverActivity" android:exported="false" android:screenOrientation="landscape" - android:theme="@style/Theme.Memby.Fullscreen" /> + android:theme="@style/Theme.Memby.Fullscreen" + tools:ignore="DiscouragedApi" /> @@ -62,14 +91,15 @@ android:screenOrientation="landscape" android:launchMode="singleTask" android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation" - android:theme="@style/Theme.Memby.Fullscreen" /> + android:theme="@style/Theme.Memby.Fullscreen" + tools:ignore="DiscouragedApi" /> diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt index 9e5eeab..34f82db 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -1,5 +1,6 @@ package com.ponzischeme89.memby +import android.annotation.SuppressLint import android.content.Context import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.MaintenanceMonitor @@ -11,6 +12,9 @@ import com.ponzischeme89.memby.data.SettingsStore * settings instance without pulling in a DI framework. */ object ServiceLocator { + // [init] only ever stores an applicationContext, whose lifetime is the process, so this + // holds nothing that could outlive its owner. Keep it that way. + @SuppressLint("StaticFieldLeak") lateinit var settings: SettingsStore private set lateinit var repository: EmbyRepository @@ -22,6 +26,6 @@ object ServiceLocator { if (::repository.isInitialized) return settings = SettingsStore(context.applicationContext) repository = EmbyRepository(settings) - maintenance = MaintenanceMonitor(repository) + maintenance = MaintenanceMonitor(repository, settings) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index d051f48..9ea5526 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -7,12 +7,14 @@ import com.ponzischeme89.memby.data.model.GatewayAuthError import com.ponzischeme89.memby.data.model.GatewayAuthPolicy import com.ponzischeme89.memby.data.model.GatewayLoginRequest import com.ponzischeme89.memby.data.model.GatewayPlaybackReport +import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayRowEvent import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayServiceStatus import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.PlaybackReport +import com.ponzischeme89.memby.data.model.PlaybackInfoRequest import com.ponzischeme89.memby.data.remote.EmbyApi import com.ponzischeme89.memby.data.remote.EmbyServiceFactory import com.ponzischeme89.memby.data.remote.GatewayApi @@ -49,6 +51,24 @@ data class HomeSnapshot( val partial: Boolean = false, ) +/** + * The episode that follows the one being watched, with everything the "next up" banner + * needs to render itself and then start playing without a second round trip. + */ +data class NextEpisode( + val itemId: String, + val title: String, + val seriesName: String, + val episodeCode: String?, + val imageUrl: String?, + val url: String, + val resumePositionMs: Long = 0L, + val subtitles: List = emptyList(), + val mediaSourceId: String = "", + val playSessionId: String = "", + val playMethod: String = "DirectPlay", +) + /** A resolved, directly playable stream. */ data class Playable( val itemId: String, @@ -56,6 +76,10 @@ data class Playable( val url: String, val resumePositionMs: Long = 0L, val logoUrl: String? = null, + val subtitles: List = emptyList(), + val mediaSourceId: String = "", + val playSessionId: String = "", + val playMethod: String = "DirectPlay", ) class EmbyRepository(private val settings: SettingsStore) { @@ -82,6 +106,10 @@ class EmbyRepository(private val settings: SettingsStore) { suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content) + suspend fun setForYouMinutes(minutes: Int) = settings.setForYouMinutes(minutes) + + suspend fun markForYouOpened() = settings.markForYouOpened() + // --- API instance caching (rebuilt only when the server URL changes) ----- private var cachedApi: EmbyApi? = null @@ -133,7 +161,12 @@ class EmbyRepository(private val settings: SettingsStore) { * Signs in. [serverUrl] is only consulted when the build does not hardwire one — * with a hardwired address the setup screen never collects it. */ - suspend fun authenticate(serverUrl: String, username: String, password: String, deviceName: String) { + suspend fun authenticate( + serverUrl: String, + username: String, + password: String, + deviceName: String, + ): String { clearPlayableCache() settings.ensureDeviceId() observedSettings = settings.snapshot() // pick up the freshly-generated device id @@ -159,16 +192,17 @@ class EmbyRepository(private val settings: SettingsStore) { require(result.token.isNotBlank() && result.userId.isNotBlank()) { "Gateway did not return a session" } + val authenticatedUsername = result.username.ifBlank { username } settings.saveSession( gateway, result.token, result.userId, - result.username.ifBlank { username }, + authenticatedUsername, result.serverId.takeIf { it.isNotBlank() }, ) settings.setDeviceName(deviceName) observedSettings = settings.snapshot() - return + return authenticatedUsername } val base = resolveServerUrl(ServerConfig.hardwiredUrl, serverUrl) @@ -183,11 +217,30 @@ class EmbyRepository(private val settings: SettingsStore) { settings.saveSession(base, token, userId, username, result.serverId) settings.setDeviceName(deviceName) observedSettings = settings.snapshot() + return username } suspend fun authPolicy(): GatewayAuthPolicy? = if (ServerConfig.isGateway) requireGateway().authPolicy() else null + /** True only when the gateway still recognises the token restored from storage. */ + suspend fun validateSession(): Boolean { + if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true + return runCatching { requireGateway().session() }.isSuccess + } + + /** + * A 401 is authoritative: cached content must not masquerade as a slow connection. + * Remove only the rejected profile, then the root UI naturally returns to sign-in. + */ + suspend fun invalidateSession() { + settings.invalidateActiveSession() + observedSettings = settings.snapshot() + cachedApi = null + cachedBaseUrl = null + clearPlayableCache() + } + suspend fun signOut() { // Retire the gateway token server-side too, so a lost TV can't keep reading the // library. A failure here must not block the local sign-out. @@ -301,19 +354,19 @@ class EmbyRepository(private val settings: SettingsStore) { /** Unfinished movies and episodes for the current Emby user. */ suspend fun getContinueWatching(limit: Int = 24): List { if (ServerConfig.isGateway) return getHome(limit).continueWatching - return getHomeItems( - params = mapOf( - "Filters" to "IsResumable", - "IncludeItemTypes" to "Movie,Episode", + val userId = snapshot.userId ?: error("Not connected") + return requireApi().getResumeItems( + userId, + mapOf( "Recursive" to "true", - "SortBy" to "DatePlayed", - "SortOrder" to "Descending", + "MediaTypes" to "Video", "Limit" to limit.toString(), + "Fields" to "RunTimeTicks,SeriesName,PrimaryImageAspectRatio", + "ImageTypeLimit" to "1", + "EnableImageTypes" to "Backdrop,Primary,Logo", + "EnableUserData" to "true", ), - fields = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio", - imageTypes = "Backdrop,Primary,Logo", - includeUserData = true, - ) + ).items } /** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */ @@ -351,8 +404,44 @@ class EmbyRepository(private val settings: SettingsStore) { } /** Library-wide search. Gateway-only: the direct path has no search UI behind it. */ - suspend fun search(term: String, limit: Int = 40): List = - 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 { + 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 { + if (!ServerConfig.isGateway) return emptyList() + return runCatching { requireGateway().recentSearches().queries } + .getOrDefault(emptyList()) + } /** * Recommendation rows on their own, forcing the gateway to build them synchronously @@ -361,6 +450,12 @@ class EmbyRepository(private val settings: SettingsStore) { */ suspend fun getRecommendations(): List = requireGateway().recommendations().rows + /** Tracearr-powered, request-scoped picks for the dedicated TV destination. */ + suspend fun getForYou(availableMinutes: Int): List { + if (!ServerConfig.isGateway) return emptyList() + return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows + } + /** * The gateway's verdict on this build. Null on the direct path, where nobody is in a * position to decide, and null on failure — an unreachable gateway must never leave @@ -383,10 +478,34 @@ class EmbyRepository(private val settings: SettingsStore) { return requireApi().getItem( userId = userId, itemId = itemId, - fields = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio", + fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio", ) } + /** + * All episodes for one show in a single request. Season switching is then a local + * list filter, keeping the detail screen immediate after its first load. + */ + suspend fun getSeriesEpisodes(seriesId: String): List { + if (seriesId.isBlank()) return emptyList() + if (ServerConfig.isGateway) { + return requireGateway().seriesEpisodes(seriesId).items + } + val userId = snapshot.userId ?: error("Not connected") + return requireApi().getEpisodes( + seriesId, + mapOf( + "UserId" to userId, + "Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio", + "EnableUserData" to "true", + "EnableImages" to "true", + "EnableImageTypes" to "Primary,Thumb,Backdrop", + "ImageTypeLimit" to "1", + "Limit" to "1000", + ), + ).items + } + /** Tight list endpoint shape: detail-only fields are never fetched on home. */ private suspend fun getHomeItems( params: Map, @@ -466,6 +585,18 @@ class EmbyRepository(private val settings: SettingsStore) { } } + /** + * Optional decoration for the player pre-roll. Failure or direct-to-Emby mode returns + * an empty schedule immediately; playback never depends on this request. + */ + suspend fun prerollSchedule(): GatewayPrerollSchedule { + if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) { + return GatewayPrerollSchedule() + } + return runCatching { requireGateway().prerollSchedule() } + .getOrDefault(GatewayPrerollSchedule()) + } + /** Backdrop rotation interval, clamped to a sane range. */ fun rotationIntervalMillis(): Long = snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L @@ -498,6 +629,97 @@ class EmbyRepository(private val settings: SettingsStore) { resolvePlayable(item) } + /** + * Resolves a new URL for an item that was already selected for playback. This bypasses + * the launch cache: recovery must not hand the player the same potentially stale URL. + * + * Passing no type hint makes the gateway ask Emby for the authoritative item. That is + * one extra request only on recovery, and avoids accidentally treating a movie as an + * episode (or resolving a series a second time). + */ + suspend fun refreshPlayableStream( + itemId: String, + title: String, + resumePositionMs: Long, + ): Playable { + require(itemId.isNotBlank()) { "A media item is required to refresh playback" } + if (!ServerConfig.isGateway) { + val discovery = directPlayback(itemId, resumePositionMs) + return Playable( + itemId = itemId, + title = title, + url = buildStreamUrl(itemId), + resumePositionMs = resumePositionMs.coerceAtLeast(0L), + subtitles = discovery.subtitles, + mediaSourceId = discovery.mediaSourceId, + playSessionId = discovery.playSessionId, + playMethod = discovery.playMethod, + ) + } + + val playback = requireGateway().playback( + itemId = itemId, + itemType = "", + title = title, + resumePositionMs = resumePositionMs.coerceAtLeast(0L), + ) + return Playable( + itemId = playback.itemId, + title = playback.title.ifBlank { title }, + url = playback.url, + // Recovery follows the local playhead. The server's persisted position may be + // up to one progress interval behind and would visibly jump the viewer back. + resumePositionMs = resumePositionMs.coerceAtLeast(0L), + subtitles = playback.subtitles, + mediaSourceId = playback.mediaSourceId, + playSessionId = playback.playSessionId, + playMethod = playback.playMethod, + ) + } + + suspend fun selectEncodedSubtitle( + session: PlaybackSession, + subtitleIndex: Int, + title: String, + positionMs: Long, + ): Playable { + if (ServerConfig.isGateway) { + val playback = requireGateway().playback( + itemId = session.itemId, + itemType = "", + title = title, + resumePositionMs = positionMs.coerceAtLeast(0L), + subtitleIndex = subtitleIndex, + ) + return Playable( + itemId = playback.itemId, + title = playback.title.ifBlank { title }, + url = playback.url, + resumePositionMs = positionMs.coerceAtLeast(0L), + subtitles = playback.subtitles, + mediaSourceId = playback.mediaSourceId, + playSessionId = playback.playSessionId, + playMethod = playback.playMethod, + ) + } + val discovery = directPlayback( + itemId = session.itemId, + positionMs = positionMs, + subtitleStreamIndex = subtitleIndex, + currentPlaySessionId = session.playSessionId, + ) + return Playable( + itemId = session.itemId, + title = title, + url = discovery.url ?: buildStreamUrl(session.itemId), + resumePositionMs = positionMs.coerceAtLeast(0L), + subtitles = discovery.subtitles, + mediaSourceId = discovery.mediaSourceId, + playSessionId = discovery.playSessionId, + playMethod = discovery.playMethod, + ) + } + private fun newPlayableRequest(item: BaseItem): Deferred { val request = scope.async(start = CoroutineStart.LAZY) { try { @@ -539,6 +761,10 @@ class EmbyRepository(private val settings: SettingsStore) { url = playback.url, resumePositionMs = playback.resumePositionMs, logoUrl = logoUrl(item), + subtitles = playback.subtitles, + mediaSourceId = playback.mediaSourceId, + playSessionId = playback.playSessionId, + playMethod = playback.playMethod, ) } if (item.isSeries) { @@ -549,20 +775,30 @@ class EmbyRepository(private val settings: SettingsStore) { append(item.name) episode.name.takeIf { it.isNotBlank() }?.let { append(" – $it") } } + val discovery = directPlayback(episode.id, episode.resumePositionMs) return Playable( episode.id, title, buildStreamUrl(episode.id), episode.resumePositionMs, logoUrl(item), + discovery.subtitles, + discovery.mediaSourceId, + discovery.playSessionId, + discovery.playMethod, ) } + val discovery = directPlayback(item.id, item.resumePositionMs) return Playable( item.id, item.name, buildStreamUrl(item.id), item.resumePositionMs, logoUrl(item), + discovery.subtitles, + discovery.mediaSourceId, + discovery.playSessionId, + discovery.playMethod, ) } @@ -574,43 +810,160 @@ class EmbyRepository(private val settings: SettingsStore) { } } - suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) { + suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) { if (ServerConfig.isGateway) { - requireGateway().report("started", GatewayPlaybackReport(itemId, positionMs)) + requireGateway().report("started", session.gatewayReport(positionMs, false, null)) return } - requireApi().reportPlaybackStarted(playbackReport(itemId, positionMs, isPaused = false)) + requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null)) } - suspend fun reportPlaybackProgress(itemId: String, positionMs: Long, isPaused: Boolean) { + suspend fun reportPlaybackProgress( + session: PlaybackSession, + positionMs: Long, + isPaused: Boolean, + eventName: String, + ) { if (ServerConfig.isGateway) { - requireGateway().report("progress", GatewayPlaybackReport(itemId, positionMs, isPaused)) + requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName)) return } - requireApi().reportPlaybackProgress(playbackReport(itemId, positionMs, isPaused)) + requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName)) } - suspend fun reportPlaybackStopped(itemId: String, positionMs: Long) { + suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) { try { if (ServerConfig.isGateway) { // Stopping is also what drops the gateway's cached rows for this user, // so Continue Watching reflects the new position on the next home load. - requireGateway().report("stopped", GatewayPlaybackReport(itemId, positionMs, isPaused = true)) + requireGateway().report("stopped", session.gatewayReport(positionMs, true, null)) } else { - requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true)) + requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null)) } } finally { clearPlayableCache() - _playbackStops.tryEmit(itemId) + _playbackStops.tryEmit(session.itemId) } } - fun enqueuePlaybackStopped(itemId: String, positionMs: Long) { + fun enqueuePlaybackStopped(session: PlaybackSession, positionMs: Long) { scope.launch { - runCatching { reportPlaybackStopped(itemId, positionMs) } + runCatching { reportPlaybackStopped(session, positionMs) } } } + /** + * The episode after [itemId], or null when nothing follows it — a movie, a series + * finale, or simply a server that would not answer. "No next episode" is an ordinary + * outcome here, so failures are swallowed: the player just shows no banner. + */ + suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? { + if (itemId.isBlank()) return null + return runCatching { + if (ServerConfig.isGateway) gatewayNextEpisode(itemId, seriesId) + else directNextEpisode(itemId, seriesId) + }.getOrNull() + } + + private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode { + val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty()) + return nextEpisodeOf( + response.item, response.url, response.resumePositionMs, response.subtitles, + response.mediaSourceId, response.playSessionId, response.playMethod, + ) + } + + private suspend fun directNextEpisode(itemId: String, seriesId: String?): NextEpisode? { + val userId = snapshot.userId ?: error("Not connected") + val series = seriesId?.takeIf { it.isNotBlank() } + ?: getItemDetails(itemId).seriesId + ?: return null + // AdjacentTo gives back [previous, current, next] in running order, minus whichever + // ends do not exist — so the current episode's position identifies the next one. + val episodes = requireApi().getEpisodes( + series, + mapOf( + "UserId" to userId, + "AdjacentTo" to itemId, + "Fields" to "RunTimeTicks,Overview,SeriesName", + "EnableUserData" to "true", + "EnableImageTypes" to "Primary,Thumb", + ), + ).items + val current = episodes.indexOfFirst { it.id == itemId }.takeIf { it >= 0 } ?: return null + val next = episodes.getOrNull(current + 1) ?: return null + val discovery = directPlayback(next.id, next.resumePositionMs) + return nextEpisodeOf( + next, buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles, + discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod, + ) + } + + private fun nextEpisodeOf( + item: BaseItem, + url: String, + resumePositionMs: Long, + subtitles: List, + mediaSourceId: String, + playSessionId: String, + playMethod: String, + ) = NextEpisode( + itemId = item.id, + title = item.name, + seriesName = item.seriesName.orEmpty(), + episodeCode = item.episodeCode, + imageUrl = primaryUrl(item, maxWidth = 400), + url = url, + resumePositionMs = resumePositionMs, + subtitles = subtitles, + mediaSourceId = mediaSourceId, + playSessionId = playSessionId, + playMethod = playMethod, + ) + + private suspend fun directPlayback( + itemId: String, + positionMs: Long, + subtitleStreamIndex: Int? = null, + currentPlaySessionId: String? = null, + ): PlaybackDiscovery { + val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId) + val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId) + val token = snapshot.token.orEmpty() + return runCatching { + val info = requireApi().getPlaybackInfo( + itemId, + userId, + body = PlaybackInfoRequest( + id = itemId, + userId = userId, + startTimeTicks = millisecondsToTicks(positionMs), + subtitleStreamIndex = subtitleStreamIndex, + currentPlaySessionId = currentPlaySessionId, + ), + ) + info.mediaSources.firstOrNull()?.let { source -> + PlaybackDiscovery( + subtitles = subtitleTracks( + streams = source.mediaStreams, + serverUrl = serverUrl, + token = token, + itemId = itemId, + mediaSourceId = source.id, + ), + mediaSourceId = source.id.ifBlank { itemId }, + playSessionId = info.playSessionId, + playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) { + "Transcode" + } else "DirectPlay", + url = source.transcodingUrl + ?.takeIf { subtitleStreamIndex != null } + ?.let { authenticatedDeliveryUrl(serverUrl, it, token) }, + ) + } ?: PlaybackDiscovery(mediaSourceId = itemId) + }.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) } + } + private suspend fun firstNextUpEpisode(userId: String, seriesId: String): BaseItem? = runCatching { requireApi().getNextUp( @@ -671,6 +1024,20 @@ class EmbyRepository(private val settings: SettingsStore) { return imageUrl(item.id, "Primary", tag, maxWidth) } + /** + * Poster for an item the app never received as a [BaseItem] — a service alert names + * its subject by id and tag only, and the Sonarr ids it carries resolve through the + * gateway's image proxy like any other. + */ + fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? { + if (itemId.isBlank() || tag.isBlank()) return null + return imageUrl(itemId, "Primary", tag, maxWidth) + } + + /** Emby stores cast portraits as the person's Primary image. */ + fun personImageUrl(person: com.ponzischeme89.memby.data.model.EmbyPerson, maxWidth: Int = 240): String? = + posterUrl(person.id, person.primaryImageTag.orEmpty(), maxWidth) + /** * Builds an artwork URL for whichever backend this build uses. * @@ -725,16 +1092,51 @@ class EmbyRepository(private val settings: SettingsStore) { "&DeviceId=${encode(deviceId)}" } - private fun playbackReport(itemId: String, positionMs: Long, isPaused: Boolean) = + private fun playbackReport( + session: PlaybackSession, + positionMs: Long, + isPaused: Boolean, + eventName: String?, + ) = PlaybackReport( - itemId = itemId, + itemId = session.itemId, + mediaSourceId = session.mediaSourceId, + playSessionId = session.playSessionId, positionTicks = millisecondsToTicks(positionMs), isPaused = isPaused, + playMethod = session.playMethod, + eventName = eventName, ) private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") } +data class PlaybackSession( + val itemId: String, + val mediaSourceId: String, + val playSessionId: String, + val playMethod: String = "DirectPlay", +) + +private data class PlaybackDiscovery( + val subtitles: List = emptyList(), + val mediaSourceId: String = "", + val playSessionId: String = "", + val playMethod: String = "DirectPlay", + val url: String? = null, +) + +private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, eventName: String?) = + GatewayPlaybackReport( + itemId = itemId, + positionMs = positionMs, + isPaused = isPaused, + mediaSourceId = mediaSourceId, + playSessionId = playSessionId, + playMethod = playMethod, + eventName = eventName, + ) + private data class CachedPlayable( val playable: Playable, val expiresAtMs: Long, @@ -754,11 +1156,13 @@ class DeviceLimitException( val maxClients: Int, ) : Exception("Memby device allowance reached") +/** Shared across the error-body parsers below; building a Json format per call is costly. */ +private val errorBodyJson = Json { ignoreUnknownKeys = true } + internal fun parseDeviceLimit(body: String): DeviceLimitException? { if (body.isBlank()) return null return runCatching { - val parsed = Json { ignoreUnknownKeys = true } - .decodeFromString(body) + val parsed = errorBodyJson.decodeFromString(body) parsed.takeIf { it.error == "device_limit_reached" && it.maxClientsPerUser > 0 }?.let { @@ -802,8 +1206,7 @@ private fun maintenanceMessage(t: HttpException): String? = runCatching { internal fun parseMaintenanceMessage(body: String): String? { if (body.isBlank()) return null return runCatching { - val parsed = Json { ignoreUnknownKeys = true } - .decodeFromString(body) + val parsed = errorBodyJson.decodeFromString(body) parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160) }.getOrNull() } @@ -811,6 +1214,9 @@ internal fun parseMaintenanceMessage(body: String): String? { /** True when this failure is the gateway reporting a deliberate outage. */ fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503 +/** True when the gateway has rejected the persisted session token. */ +fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401 + @Serializable private data class MaintenanceResponse( val maintenance: Boolean = false, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index 0480ce6..e21963a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -44,12 +44,18 @@ data class Settings( val updateToken: String? = null, // Show each item's Emby "Logo" image in place of the plain-text title. val showTitleLogo: Boolean = true, + // Slide up a "next up" banner near the end of an episode and roll into the next one. + val autoPlayNextEpisode: Boolean = true, // Foreground colour of the slide-progress ring, as an RRGGBB hex string. val ringColorHex: String = DEFAULT_RING_COLOR, val lastBackdropUrl: String? = null, /** Comma-separated, user-controlled order of rows shown on the client home. */ val homeSections: String = DEFAULT_HOME_SECTIONS, val homeCacheJson: String? = null, + /** Last duration chosen in For You, scoped to the active Emby profile on this TV. */ + val forYouMinutes: Int = 0, + /** Whether this profile has discovered the dedicated For You destination. */ + val hasOpenedForYou: Boolean = false, val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY, val showHomeCardMetadata: Boolean = true, val profiles: List = emptyList(), @@ -80,6 +86,8 @@ data class EmbyProfile( val username: String, val serverId: String? = null, val homeCacheJson: String? = null, + val forYouMinutes: Int = 0, + val hasOpenedForYou: Boolean = false, ) class SettingsStore(private val context: Context) { @@ -91,6 +99,7 @@ class SettingsStore(private val context: Context) { get() = latestSettings private object Keys { + const val MAX_SEEN_ALERTS = 40 val SERVER_URL = stringPreferencesKey("server_url") val TOKEN = stringPreferencesKey("token") val USER_ID = stringPreferencesKey("user_id") @@ -103,13 +112,17 @@ class SettingsStore(private val context: Context) { val UPDATE_REPO = stringPreferencesKey("update_repo") val UPDATE_TOKEN = stringPreferencesKey("update_token") val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo") + val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode") val RING_COLOR = stringPreferencesKey("ring_color") val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url") val HOME_SECTIONS = stringPreferencesKey("home_sections") val HOME_CACHE = stringPreferencesKey("home_cache") + val FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes") + val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you") val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density") val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata") val PROFILES = stringPreferencesKey("profiles") + val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids") } /** @@ -145,10 +158,37 @@ class SettingsStore(private val context: Context) { } } + /** + * Ids of alerts already shown on this TV. The gateway keeps offering an alert for as + * long as it is current, so without this an "aired" banner would return every poll — + * and again after every relaunch. Only the most recent ids are kept; older ones have + * long since fallen out of the server's window. + */ + suspend fun seenAlertIds(): Set = + 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 = + raw?.split('\n')?.filter { it.isNotBlank() }.orEmpty() + suspend fun setShowTitleLogo(enabled: Boolean) { context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled } } + suspend fun setAutoPlayNextEpisode(enabled: Boolean) { + context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled } + } + suspend fun setRingColor(hex: String) { context.dataStore.edit { it[Keys.RING_COLOR] = hex } } @@ -194,6 +234,39 @@ class SettingsStore(private val context: Context) { } } + suspend fun setForYouMinutes(minutes: Int) { + val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0 + context.dataStore.edit { preferences -> + preferences[Keys.FOR_YOU_MINUTES] = selected + updateActiveProfile(preferences) { it.copy(forYouMinutes = selected) } + } + } + + suspend fun markForYouOpened() { + context.dataStore.edit { preferences -> + preferences[Keys.HAS_OPENED_FOR_YOU] = true + updateActiveProfile(preferences) { it.copy(hasOpenedForYou = true) } + } + } + + private fun updateActiveProfile( + preferences: MutablePreferences, + transform: (EmbyProfile) -> EmbyProfile, + ) { + val activeUserId = preferences[Keys.USER_ID] + val activeServer = preferences[Keys.SERVER_URL] + val profiles = profilesFrom(preferences).map { profile -> + if (profile.userId == activeUserId && profile.serverUrl == activeServer) { + transform(profile) + } else { + profile + } + } + if (profiles.isNotEmpty()) { + preferences[Keys.PROFILES] = Json.encodeToString(profiles) + } + } + fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let { runCatching { Json.decodeFromString(it) }.getOrNull() } @@ -227,6 +300,8 @@ class SettingsStore(private val context: Context) { username = username, serverId = serverId, homeCacheJson = previous?.homeCacheJson, + forYouMinutes = previous?.forYouMinutes ?: 0, + hasOpenedForYou = previous?.hasOpenedForYou ?: false, ) profiles.removeAll { it.id == id } profiles.add(profile) @@ -248,17 +323,40 @@ class SettingsStore(private val context: Context) { suspend fun clearSession() { context.dataStore.edit { - it.remove(Keys.SERVER_URL) - it.remove(Keys.TOKEN) - it.remove(Keys.USER_ID) - it.remove(Keys.SERVER_ID) - it.remove(Keys.LAST_BACKDROP_URL) - it.remove(Keys.HOME_CACHE) - it.remove(Keys.USERNAME) + clearActiveSession(it) // Intentionally keep DEVICE_ID stable across sign-outs. } } + /** + * Drops a server-rejected profile as well as the active session. Keeping the stale + * profile would let the profile picker immediately restore the same dead token and + * trap the viewer in a 401 loop. + */ + suspend fun invalidateActiveSession() { + context.dataStore.edit { preferences -> + val activeUserId = preferences[Keys.USER_ID] + val activeServer = preferences[Keys.SERVER_URL] + val remaining = profilesFrom(preferences).filterNot { + it.userId == activeUserId && it.serverUrl == activeServer + } + preferences[Keys.PROFILES] = Json.encodeToString(remaining) + clearActiveSession(preferences) + } + } + + private fun clearActiveSession(preferences: MutablePreferences) { + preferences.remove(Keys.SERVER_URL) + preferences.remove(Keys.TOKEN) + preferences.remove(Keys.USER_ID) + preferences.remove(Keys.SERVER_ID) + preferences.remove(Keys.LAST_BACKDROP_URL) + preferences.remove(Keys.HOME_CACHE) + preferences.remove(Keys.FOR_YOU_MINUTES) + preferences.remove(Keys.HAS_OPENED_FOR_YOU) + preferences.remove(Keys.USERNAME) + } + private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) { preferences[Keys.SERVER_URL] = profile.serverUrl preferences[Keys.TOKEN] = profile.token @@ -268,6 +366,8 @@ class SettingsStore(private val context: Context) { else preferences[Keys.SERVER_ID] = profile.serverId if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE) else preferences[Keys.HOME_CACHE] = profile.homeCacheJson + preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes + preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou preferences.remove(Keys.LAST_BACKDROP_URL) } @@ -284,6 +384,8 @@ class SettingsStore(private val context: Context) { username = username, serverId = preferences[Keys.SERVER_ID], homeCacheJson = preferences[Keys.HOME_CACHE], + forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0, + hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false, ) } @@ -310,10 +412,13 @@ class SettingsStore(private val context: Context) { updateRepo = preferences[Keys.UPDATE_REPO], updateToken = preferences[Keys.UPDATE_TOKEN], showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true, + autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true, ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR, lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL], homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS, homeCacheJson = preferences[Keys.HOME_CACHE], + forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0, + hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false, homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY, showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true, profiles = profiles, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index 291fd9b..24deb1a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -38,11 +38,84 @@ data class UserItemData( @Serializable data class PlaybackReport( @SerialName("ItemId") val itemId: String, + @SerialName("MediaSourceId") val mediaSourceId: String, + @SerialName("PlaySessionId") val playSessionId: String, @SerialName("PositionTicks") val positionTicks: Long = 0, @SerialName("IsPaused") val isPaused: Boolean = false, @SerialName("IsMuted") val isMuted: Boolean = false, @SerialName("CanSeek") val canSeek: Boolean = true, @SerialName("PlayMethod") val playMethod: String = "DirectPlay", + @SerialName("EventName") val eventName: String? = null, +) + +@Serializable +data class PlaybackInfoRequest( + @SerialName("Id") val id: String, + @SerialName("UserId") val userId: String, + @SerialName("IsPlayback") val isPlayback: Boolean = true, + @SerialName("StartTimeTicks") val startTimeTicks: Long = 0, + @SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null, + @SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null, + @SerialName("DeviceProfile") val deviceProfile: DeviceProfile = DeviceProfile.embyAndroidTv(), +) + +@Serializable +data class DeviceProfile( + @SerialName("Name") val name: String, + @SerialName("SupportedMediaTypes") val supportedMediaTypes: String = "Video", + @SerialName("SubtitleProfiles") val subtitleProfiles: List, + @SerialName("DirectPlayProfiles") val directPlayProfiles: List, + @SerialName("TranscodingProfiles") val transcodingProfiles: List, +) { + companion object { + fun embyAndroidTv() = DeviceProfile( + name = "Memby Android TV", + subtitleProfiles = listOf( + "srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g", + ).map { SubtitleProfile(it, "External") } + listOf( + "pgs", "pgssub", "sup", "vobsub", "dvdsub", "dvbsub", + ).map { SubtitleProfile(it, "Encode") }, + directPlayProfiles = listOf( + DirectPlayProfile( + container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi", + videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4", + audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm", + ), + ), + transcodingProfiles = listOf( + TranscodingProfile( + container = "ts", + videoCodec = "h264", + audioCodec = "aac", + protocol = "hls", + ), + ), + ) + } +} + +@Serializable +data class DirectPlayProfile( + @SerialName("Container") val container: String, + @SerialName("VideoCodec") val videoCodec: String, + @SerialName("AudioCodec") val audioCodec: String, + @SerialName("Type") val type: String = "Video", +) + +@Serializable +data class TranscodingProfile( + @SerialName("Container") val container: String, + @SerialName("VideoCodec") val videoCodec: String, + @SerialName("AudioCodec") val audioCodec: String, + @SerialName("Protocol") val protocol: String, + @SerialName("Type") val type: String = "Video", + @SerialName("Context") val context: String = "Streaming", +) + +@Serializable +data class SubtitleProfile( + @SerialName("Format") val format: String, + @SerialName("Method") val method: String, ) @Serializable @@ -52,9 +125,20 @@ data class Studio( @Serializable data class MediaStream( + @SerialName("Index") val index: Int = -1, @SerialName("Type") val type: String = "", @SerialName("Codec") val codec: String? = null, @SerialName("Title") val title: String? = null, + @SerialName("DisplayTitle") val displayTitle: String? = null, + @SerialName("Language") val language: String? = null, + @SerialName("IsDefault") val isDefault: Boolean = false, + @SerialName("IsForced") val isForced: Boolean = false, + @SerialName("IsHearingImpaired") val isHearingImpaired: Boolean = false, + @SerialName("IsExternal") val isExternal: Boolean = false, + @SerialName("IsTextSubtitleStream") val isTextSubtitleStream: Boolean = false, + @SerialName("SupportsExternalStream") val supportsExternalStream: Boolean = false, + @SerialName("DeliveryUrl") val deliveryUrl: String? = null, + @SerialName("DeliveryMethod") val deliveryMethod: String? = null, @SerialName("Width") val width: Int? = null, @SerialName("Height") val height: Int? = null, @SerialName("VideoRange") val videoRange: String? = null, @@ -62,6 +146,31 @@ data class MediaStream( @SerialName("Channels") val channels: Int? = null, ) +@Serializable +data class PlaybackInfo( + @SerialName("MediaSources") val mediaSources: List = emptyList(), + @SerialName("PlaySessionId") val playSessionId: String = "", +) + +@Serializable +data class MediaSourceInfo( + @SerialName("Id") val id: String = "", + @SerialName("MediaStreams") val mediaStreams: List = emptyList(), + @SerialName("DirectStreamUrl") val directStreamUrl: String? = null, + @SerialName("TranscodingUrl") val transcodingUrl: String? = null, +) + +@Serializable +data class EmbyPerson( + @SerialName("Id") val id: String = "", + @SerialName("Name") val name: String = "", + @SerialName("Role") val role: String? = null, + @SerialName("Type") val type: String = "", + @SerialName("PrimaryImageTag") val primaryImageTag: String? = null, +) { + val isCastMember: Boolean get() = type.equals("Actor", ignoreCase = true) +} + @Serializable data class BaseItem( @SerialName("Id") val id: String, @@ -76,11 +185,15 @@ data class BaseItem( @SerialName("RunTimeTicks") val runTimeTicks: Long? = null, @SerialName("Genres") val genres: List = emptyList(), @SerialName("MediaStreams") val mediaStreams: List = emptyList(), + @SerialName("People") val people: List = emptyList(), @SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null, @SerialName("BackdropImageTags") val backdropImageTags: List = emptyList(), @SerialName("ImageTags") val imageTags: Map = emptyMap(), @SerialName("SeriesId") val seriesId: String? = null, @SerialName("SeriesName") val seriesName: String? = null, + // Emby returns these on episodes without being asked, so they cost no extra Fields. + @SerialName("IndexNumber") val indexNumber: Int? = null, + @SerialName("ParentIndexNumber") val parentIndexNumber: Int? = null, @SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null, @SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List = emptyList(), @SerialName("ParentLogoItemId") val parentLogoItemId: String? = null, @@ -96,12 +209,26 @@ data class BaseItem( @SerialName("MembyAvailability") val membyAvailability: String? = null, @SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null, @SerialName("MembyPlayable") val membyPlayable: Boolean = true, + // Derived by the TV from the Sonarr schedule row and retained in the local home cache. + @SerialName("MembyAiringToday") val membyAiringToday: Boolean = false, + // Explainability supplied only by the gateway's dedicated For You endpoint. + @SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null, + @SerialName("MembyCompatibility") val membyCompatibility: String? = null, ) { val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true) val isSeries: Boolean get() = type.equals("Series", ignoreCase = true) val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true) val isFavorite: Boolean get() = userData?.isFavorite == true val isSonarrSchedule: Boolean get() = membySource == "sonarr" + val cast: List get() = people.filter(EmbyPerson::isCastMember) + + /** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */ + val episodeCode: String? + get() { + val episode = indexNumber ?: return null + val season = parentIndexNumber + return if (season != null) "S$season · E$episode" else "E$episode" + } /** Runtime in whole minutes, or null when unknown. */ val runtimeMinutes: Int? diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 7532cc1..f35c9b2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -104,6 +104,29 @@ data class GatewayUpdate( data class GatewayServiceStatus( val maintenance: Boolean = false, val message: String = "", + val alerts: List = emptyList(), + val compatible: Boolean = true, + val compatibilityMessage: String = "", + val clientVersion: String = "", + val clientProtocol: String = "", + val serverProtocol: Int = 0, +) + +/** + * An informational nudge riding along on the status poll — currently "this episode aired + * and is on its way into Emby". It is never actionable: the banner slides in, states the + * news and leaves. Unknown [kind] values still render, so the server can add one without + * an app release. + */ +@Serializable +data class GatewayAlert( + val id: String = "", + val kind: String = "", + val title: String = "", + val message: String = "", + val itemId: String = "", + val imageTag: String = "", + val airedAt: String = "", ) /** Response of `GET /v1/recommendations`. */ @@ -117,12 +140,52 @@ data class GatewayItems( val items: List = emptyList(), ) +@Serializable +data class GatewaySearchHistory( + val queries: List = emptyList(), +) + +@Serializable +data class GatewayPrerollSchedule( + val today: List = emptyList(), + val thisWeek: List = emptyList(), +) + +@Serializable +data class GatewayPrerollEntry( + val series: String = "", + val episode: String = "", + val episodeCode: String = "", + val schedule: String = "", + val availability: String = "", +) + @Serializable data class GatewayPlayback( val itemId: String, val title: String = "", val url: String, val resumePositionMs: Long = 0, + val subtitles: List = 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 = emptyList(), + val mediaSourceId: String = "", + val playSessionId: String = "", + val playMethod: String = "DirectPlay", ) @Serializable @@ -151,4 +214,8 @@ data class GatewayPlaybackReport( val itemId: String, val positionMs: Long, val isPaused: Boolean = false, + val mediaSourceId: String = "", + val playSessionId: String = "", + val playMethod: String = "DirectPlay", + val eventName: String? = null, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt index 5277d8b..58cfa85 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt @@ -4,6 +4,8 @@ import com.ponzischeme89.memby.data.model.AuthRequest import com.ponzischeme89.memby.data.model.AuthResult import com.ponzischeme89.memby.data.model.ItemsResult import com.ponzischeme89.memby.data.model.PlaybackReport +import com.ponzischeme89.memby.data.model.PlaybackInfo +import com.ponzischeme89.memby.data.model.PlaybackInfoRequest import com.ponzischeme89.memby.data.model.UserItemData import retrofit2.http.Body import retrofit2.http.DELETE @@ -31,6 +33,20 @@ interface EmbyApi { @Query("Fields") fields: String, ): com.ponzischeme89.memby.data.model.BaseItem + @POST("Items/{itemId}/PlaybackInfo") + suspend fun getPlaybackInfo( + @Path("itemId") itemId: String, + @Query("UserId") userId: String, + @Query("IsPlayback") isPlayback: Boolean = true, + @Body body: PlaybackInfoRequest, + ): PlaybackInfo + + @GET("Users/{userId}/Items/Resume") + suspend fun getResumeItems( + @Path("userId") userId: String, + @QueryMap params: Map, + ): ItemsResult + @GET("Users/{userId}/Items/{itemId}/LocalTrailers") suspend fun getLocalTrailers( @Path("userId") userId: String, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index f92cdd6..1c4f463 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -7,10 +7,13 @@ import com.ponzischeme89.memby.data.model.GatewayHome import com.ponzischeme89.memby.data.model.GatewayItems import com.ponzischeme89.memby.data.model.GatewayLoginRequest import com.ponzischeme89.memby.data.model.GatewayLoginResponse +import com.ponzischeme89.memby.data.model.GatewayNextEpisode import com.ponzischeme89.memby.data.model.GatewayPlayback import com.ponzischeme89.memby.data.model.GatewayPlaybackReport +import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayRows +import com.ponzischeme89.memby.data.model.GatewaySearchHistory import com.ponzischeme89.memby.data.model.GatewayServiceStatus import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.UserItemData @@ -38,6 +41,10 @@ interface GatewayApi { @POST("v1/auth/logout") suspend fun logout() + /** Confirms that a token restored from TV storage still exists on the gateway. */ + @GET("v1/auth/session") + suspend fun session(): GatewayLoginResponse + @GET("v1/home") suspend fun home(@Query("limit") limit: Int): GatewayHome @@ -47,10 +54,22 @@ interface GatewayApi { @GET("v1/search") suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems + @POST("v1/search/history") + suspend fun recordSearch(@Body body: Map) + + @GET("v1/search/history") + suspend fun recentSearches(): GatewaySearchHistory + /** Recommendation rows on their own. `/v1/home` already embeds these when warm. */ @GET("v1/recommendations") suspend fun recommendations(): GatewayRows + @GET("v1/for-you") + suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows + + @GET("v1/preroll") + suspend fun prerollSchedule(): GatewayPrerollSchedule + /** * Whether this build should update. The version travels as a header on every request * (see GatewayServiceFactory), so there is nothing to pass here. @@ -65,14 +84,26 @@ interface GatewayApi { @GET("v1/items/{id}") suspend fun item(@Path("id") itemId: String): BaseItem + /** All episodes for a series in display order; the client groups them into seasons. */ + @GET("v1/items/{id}/episodes") + suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems + @GET("v1/items/{id}/playback") suspend fun playback( @Path("id") itemId: String, @Query("type") itemType: String, @Query("title") title: String, @Query("resumePositionMs") resumePositionMs: Long, + @Query("subtitleIndex") subtitleIndex: Int? = null, ): GatewayPlayback + /** 404 when nothing follows this item: a movie, or a series finale. */ + @GET("v1/items/{id}/next") + suspend fun nextEpisode( + @Path("id") itemId: String, + @Query("seriesId") seriesId: String, + ): GatewayNextEpisode + @GET("v1/items/{id}/trailer") suspend fun trailer(@Path("id") itemId: String): BaseItem diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index 650e0a6..57c6bea 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -51,9 +51,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : // The gateway decides whether this build needs updating, so every request // says which build it is. .header("X-Memby-Version", BuildConfig.VERSION_NAME) + .header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString()) tokenProvider()?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", "Bearer $it") } return chain.proceed(builder.build()) } } + +internal const val MEMBY_PROTOCOL_VERSION = 1 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 1121b51..1278777 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -3,12 +3,14 @@ package com.ponzischeme89.memby.ui import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -26,14 +28,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,17 +48,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -76,12 +81,14 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PlayCircleFilled import androidx.compose.material.icons.filled.Recommend +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SentimentVerySatisfied import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SkipNext @@ -95,21 +102,27 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.EmbyPerson import java.util.Locale import kotlinx.coroutines.delay import kotlinx.coroutines.launch private val EmbyGreen = Color(0xFF52B54B) private val RailSurface = Color(0xF20C0F12) -private val MutedText = Color(0xFFB7BDC3) -private val QuietText = Color(0xFF8C949B) +// High-contrast neutrals tuned for the app's near-black surfaces. Quiet remains visibly +// secondary from TV distance without falling into low-contrast grey-on-black. +private val MutedText = Color(0xFFD0D6DB) +private val QuietText = Color(0xFFAEB7BF) internal val TvRailCollapsedWidth = 54.dp internal val TvRailExpandedWidth = 184.dp internal val TvRailContentShift = 112.dp enum class BrowseDestination(val label: String, val icon: ImageVector) { HOME("Home", Icons.Default.Home), + FOR_YOU("For You", Icons.Default.AutoAwesome), + SEARCH("Search", Icons.Default.Search), MOVIES("Movies", Icons.Default.Movie), SHOWS("TV Shows", Icons.Default.Tv), FAVORITES("Favourites", Icons.Default.Favorite), @@ -131,53 +144,44 @@ data class HomeBrowseRow( private data class HomeRowVisual( val icon: ImageVector, - val colors: List, ) private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when { row.id == "continue" -> HomeRowVisual( Icons.Default.PlayCircleFilled, - listOf(Color(0xFF3A7BD5), Color(0xFF6C4DFF)), ) row.id == "next-up" -> HomeRowVisual( Icons.Default.SkipNext, - listOf(Color(0xFF00A896), Color(0xFF28C2A0)), ) row.kind == MediaRowKind.FAVORITES -> HomeRowVisual( Icons.Default.Favorite, - listOf(Color(0xFFFF5C8A), Color(0xFFB84DFF)), ) row.id == "latest-movies" -> HomeRowVisual( Icons.Default.Movie, - listOf(Color(0xFFFF8A3D), Color(0xFFE34B6F)), ) row.id == "sonarr-airing-today" -> HomeRowVisual( Icons.Default.CalendarMonth, - listOf(Color(0xFF00A9CE), Color(0xFF4169E1)), ) row.id == "curated:apple-tv" -> HomeRowVisual( Icons.Default.LiveTv, - listOf(Color(0xFF252A31), Color(0xFF66717E)), ) row.id == "curated:drama-shows" -> HomeRowVisual( Icons.Default.TheaterComedy, - listOf(Color(0xFF6A3FB5), Color(0xFFB64272)), ) row.id == "curated:comedy-shows" -> HomeRowVisual( Icons.Default.SentimentVerySatisfied, - listOf(Color(0xFFFFB52E), Color(0xFFFF7433)), ) row.id.startsWith("similar:") -> HomeRowVisual( Icons.Default.AutoAwesome, - listOf(Color(0xFF8A5CF6), Color(0xFFE458A3)), ) row.id == "recommended" -> HomeRowVisual( Icons.Default.Recommend, - listOf(Color(0xFF45B649), Color(0xFF00A896)), + ) + row.id.startsWith("for-you:") -> HomeRowVisual( + Icons.Default.AutoAwesome, ) else -> HomeRowVisual( Icons.Default.VideoLibrary, - listOf(Color(0xFF536976), Color(0xFF738A96)), ) } @@ -186,12 +190,34 @@ fun TvNavigationRail( selected: BrowseDestination, expanded: Boolean, navigationFocusRequester: FocusRequester, - contentFocusRequester: FocusRequester, onRailFocusChanged: (Boolean) -> Unit, onDestinationSelected: (BrowseDestination) -> Unit, modifier: Modifier = Modifier, ) { - val railWidth by animateDpAsState( + var railHasFocus by remember { mutableStateOf(false) } + val logoScale = remember { Animatable(0.72f) } + val logoAlpha = remember { Animatable(0f) } + val logoRotation = remember { Animatable(0f) } + LaunchedEffect(Unit) { + kotlinx.coroutines.coroutineScope { + launch { logoScale.animateTo(1f, tween(620)) } + launch { logoAlpha.animateTo(1f, tween(420)) } + } + } + LaunchedEffect(selected) { + if (selected == BrowseDestination.HOME) { + logoRotation.snapTo(0f) + logoRotation.animateTo(360f, tween(850)) + } + } + LaunchedEffect(railHasFocus) { + if (railHasFocus) { + // Spatial focus may initially choose the first rail item (Home). Correct that + // only as focus enters the rail; subsequent up/down movement must remain free. + runCatching { navigationFocusRequester.requestFocus() } + } + } + val railWidth = animateDpAsState( targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth, animationSpec = tween(160), label = "navigation-rail-width", @@ -207,10 +233,30 @@ fun TvNavigationRail( // The parent deliberately reports only the collapsed footprint to the // home Row. requiredWidth lets the focused surface draw outward without // remeasuring gallery cards or clipping their labels to 54dp. - .wrapContentSize(Alignment.TopStart, unbounded = true) - .requiredWidth(railWidth) + // Only width may escape the collapsed 54dp footprint. Unbounding height + // here would make fillMaxHeight lose the screen constraint and stop the + // rail surface at its final child (the version label). + .wrapContentWidth(Alignment.Start, unbounded = true) + // Read in the measure lambda, not the modifier chain: expanding the rail + // is the most frequent animation in the app, and reading the animated Dp + // during composition recomposes the rail and every item in it per frame. + .layout { measurable, constraints -> + val width = railWidth.value.roundToPx() + val placeable = measurable.measure( + constraints.copy(minWidth = width, maxWidth = width), + ) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } .fillMaxHeight() .background(RailSurface) + .onFocusChanged { focusState -> + val hasFocus = focusState.hasFocus + if (railHasFocus != hasFocus) { + railHasFocus = hasFocus + onRailFocusChanged(hasFocus) + } + } + .focusGroup() .padding(horizontal = 5.dp, vertical = 15.dp), horizontalAlignment = Alignment.Start, ) { @@ -222,7 +268,15 @@ fun TvNavigationRail( Image( painter = painterResource(R.drawable.emby_logo), contentDescription = "Memby", - modifier = Modifier.width(32.dp).height(27.dp), + modifier = Modifier + .width(32.dp) + .height(27.dp) + .graphicsLayer { + scaleX = logoScale.value + scaleY = logoScale.value + alpha = logoAlpha.value + rotationZ = logoRotation.value + }, ) if (expanded) { Column(verticalArrangement = Arrangement.Center) { @@ -251,12 +305,20 @@ fun TvNavigationRail( selected = destination == selected, expanded = expanded, modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier, - contentFocusRequester = contentFocusRequester, - onFocused = { onRailFocusChanged(true) }, + onFocused = {}, onClick = { onDestinationSelected(destination) }, ) Spacer(Modifier.height(4.dp)) } + Spacer(Modifier.weight(1f)) + Text( + if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}", + color = QuietText, + fontSize = if (expanded) 10.sp else 8.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp), + ) } } } @@ -266,13 +328,14 @@ fun ExpandableNavigationItem( destination: BrowseDestination, selected: Boolean, expanded: Boolean, - contentFocusRequester: FocusRequester, onFocused: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, ) { var focused by remember { mutableStateOf(false) } - val background by animateColorAsState( + // Not `by`: both colours are read in the draw phase / at the point of use, so the + // 85ms focus transition repaints this row rather than recomposing it. + val background = animateColorAsState( targetValue = when { focused -> Color.White.copy(alpha = 0.13f) selected -> EmbyGreen.copy(alpha = 0.10f) @@ -281,7 +344,7 @@ fun ExpandableNavigationItem( animationSpec = tween(85), label = "navigation-item-background", ) - val foreground by animateColorAsState( + val foreground = animateColorAsState( targetValue = when { focused -> Color.White selected -> EmbyGreen @@ -294,13 +357,12 @@ fun ExpandableNavigationItem( modifier = modifier .fillMaxWidth() .height(44.dp) - .focusProperties { right = contentFocusRequester } .onFocusChanged { focused = it.isFocused if (it.isFocused) onFocused() } .clip(RoundedCornerShape(8.dp)) - .background(background) + .drawBehind { drawRect(background.value) } .clickable(onClick = onClick) .semantics { contentDescription = destination.label } .padding(horizontal = 8.dp), @@ -308,7 +370,7 @@ fun ExpandableNavigationItem( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) { - Icon(destination.icon, contentDescription = null, tint = foreground, modifier = Modifier.size(21.dp)) + Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp)) if (selected) { Box( Modifier @@ -322,7 +384,7 @@ fun ExpandableNavigationItem( if (expanded) { Text( destination.label, - color = foreground, + color = foreground.value, fontSize = 15.sp, fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium, maxLines = 1, @@ -390,7 +452,6 @@ fun MediaMetadataPanel( item: BaseItem?, loading: Boolean, sectionLabel: String, - contentFocusRequester: FocusRequester, navigationFocusRequester: FocusRequester, onPlay: (BaseItem) -> Unit, onContentFocused: () -> Unit, @@ -414,7 +475,6 @@ fun MediaMetadataPanel( onClick = { item?.let(onPlay) }, enabled = item != null, modifier = Modifier - .focusRequester(contentFocusRequester) .focusProperties { left = navigationFocusRequester } .onFocusChanged { if (it.isFocused) onContentFocused() }, ) { @@ -477,7 +537,7 @@ fun MediaDetailsOverlay( modifier = Modifier .fillMaxHeight() .fillMaxWidth(0.68f) - .padding(start = 72.dp, end = 36.dp, top = 60.dp, bottom = 48.dp), + .padding(start = 72.dp, end = 36.dp, top = 38.dp, bottom = 32.dp), verticalArrangement = Arrangement.Center, ) { Text( @@ -517,16 +577,24 @@ fun MediaDetailsOverlay( maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (item.membyAiringToday) { + Spacer(Modifier.height(8.dp)) + MediaBadge("AIRING TODAY") + } Spacer(Modifier.height(16.dp)) Text( item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = Color(0xFFD8DCDF), fontSize = 17.sp, lineHeight = 23.sp, - maxLines = 4, + maxLines = 3, overflow = TextOverflow.Ellipsis, ) - Spacer(Modifier.height(22.dp)) + if (item.cast.isNotEmpty()) { + Spacer(Modifier.height(14.dp)) + CastRail(item.cast, compact = true) + } + Spacer(Modifier.height(16.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { onPlay(item) }, @@ -636,7 +704,21 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) { if (facts.isNotEmpty()) { Text(facts.joinToString(" • "), color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } - val badges = mediaBadges(item) + val badges = buildList { + if (item.membyAiringToday) add("AIRING TODAY") + addAll(mediaBadges(item)) + } + item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let { + Text( + text = it, + color = EmbyGreen, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(0.78f), + ) + } if (badges.isNotEmpty()) { Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { badges.forEach { MediaBadge(it) } @@ -785,6 +867,11 @@ fun MediaRow( ) { val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } val scope = rememberCoroutineScope() + // firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so + // only the button's enabled/disabled flip recomposes the row header. + val canScrollBack by remember(rowState) { + derivedStateOf { rowState.firstVisibleItemIndex > 0 } + } val pageSize = if ( row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT ) 6 else 4 @@ -798,8 +885,8 @@ fun MediaRow( modifier = Modifier .size(28.dp) .clip(CircleShape) - .background(Brush.linearGradient(visual.colors)) - .border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape), + .background(Color.White.copy(alpha = 0.08f)) + .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape), contentAlignment = Alignment.Center, ) { Icon( @@ -814,13 +901,13 @@ fun MediaRow( row.title, color = Color(0xFFF1F3F4), fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, ) Spacer(Modifier.weight(1f)) if (row.items.isNotEmpty()) { GalleryJumpButton( forward = false, - enabled = rowState.firstVisibleItemIndex > 0, + enabled = canScrollBack, onClick = { val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0) scope.launch { rowState.scrollToItem(target) } @@ -852,6 +939,12 @@ fun MediaRow( } } } + row.items.isEmpty() && row.id == "favourite-shows" -> { + FavoriteShowsEmptyState( + navigationFocusRequester = navigationFocusRequester, + onContentFocused = onContentFocused, + ) + } row.items.isEmpty() -> { Text( row.emptyMessage, @@ -865,7 +958,11 @@ fun MediaRow( state = rowState, contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth().focusGroup().focusRestorer(), + // focusRestorer pins a lazy item. Replacing a For You result set + // can dispose that item during a focus transfer and make Compose + // release the same pin twice. LazyListState already preserves the + // row position; native TV spatial search safely handles row changes. + modifier = Modifier.fillMaxWidth().focusGroup(), ) { itemsIndexed( row.items, @@ -874,7 +971,6 @@ fun MediaRow( ) { index, item -> var cardModifier: Modifier = Modifier if (index == 0) { - cardModifier = cardModifier.focusProperties { left = navigationFocusRequester } if (contentEntryFocusRequester != null) { cardModifier = cardModifier.focusRequester(contentEntryFocusRequester) } @@ -910,6 +1006,172 @@ fun MediaRow( } } +@Composable +internal fun CastRail( + people: List, + modifier: Modifier = Modifier, + compact: Boolean = false, + showTitle: Boolean = true, +) { + val cast = remember(people) { people.filter(EmbyPerson::isCastMember).take(16) } + if (cast.isEmpty()) return + Column(modifier) { + if (showTitle) { + Text( + "Cast", + color = Color.White, + fontSize = if (compact) 16.sp else 21.sp, + fontWeight = FontWeight.SemiBold, + ) + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 16.dp), + contentPadding = PaddingValues( + top = if (showTitle) 7.dp else 3.dp, + end = 24.dp, + bottom = 4.dp, + ), + ) { + items(cast, key = { "${it.id}:${it.name}" }) { person -> + CastCard(person, compact) + } + } + } +} + +@Composable +private fun CastCard(person: EmbyPerson, compact: Boolean) { + val repository = ServiceLocator.repository + val portrait = remember(person.id, person.primaryImageTag) { + repository.personImageUrl(person, if (compact) 180 else 280) + } + var focused by remember { mutableStateOf(false) } + val width = if (compact) 72.dp else 112.dp + val height = if (compact) 76.dp else 142.dp + val shape = RoundedCornerShape(if (compact) 8.dp else 10.dp) + val scale by animateFloatAsState( + targetValue = if (focused) 1.045f else 1f, + animationSpec = tween(110), + label = "cast-card-focus", + ) + Column( + Modifier + .width(width) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .onFocusChanged { focused = it.isFocused } + .focusable(), + ) { + Box( + Modifier + .fillMaxWidth() + .height(height) + .clip(shape) + .background(Color(0xFF24292E)) + .border( + if (focused) 2.dp else 1.dp, + if (focused) Color.White else Color.White.copy(alpha = 0.10f), + shape, + ), + contentAlignment = Alignment.Center, + ) { + if (portrait != null) { + AsyncImage( + model = portrait, + contentDescription = person.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Icon( + Icons.Default.Person, + contentDescription = null, + tint = QuietText, + modifier = Modifier.size(if (compact) 28.dp else 40.dp), + ) + } + } + Text( + person.name, + color = Color.White, + fontSize = if (compact) 11.sp else 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 5.dp), + ) + person.role?.takeIf(String::isNotBlank)?.let { role -> + Text( + role, + color = QuietText, + fontSize = if (compact) 9.sp else 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun FavoriteShowsEmptyState( + navigationFocusRequester: FocusRequester, + onContentFocused: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 36.dp, vertical = 10.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = if (focused) 0.11f else 0.055f)) + .border( + 2.dp, + if (focused) Color.White else Color.White.copy(alpha = 0.14f), + RoundedCornerShape(14.dp), + ) + .focusProperties { left = navigationFocusRequester } + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) onContentFocused() + } + .focusable() + .padding(horizontal = 22.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Box( + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.09f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.FavoriteBorder, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(25.dp), + ) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "No favorite shows yet", + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + Text( + "Mark a series as a favorite and it’ll be waiting here.", + color = MutedText, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} + @Composable private fun GalleryJumpButton( forward: Boolean, @@ -971,6 +1233,28 @@ fun PortraitMediaCard( ) } +/** + * A poster card at an explicit width, for grids rather than rows. + * + * [PortraitMediaCard] derives its width from the row's available width, which is the + * wrong input for a grid whose column count is already decided. Same card underneath — + * same artwork loading, badges and focus treatment. + */ +@Composable +fun PosterGridCard( + item: BaseItem, + width: Dp, + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + MediaCard( + item, width, 2f / 3f, preferPrimary = true, showProgress = false, + showSecondaryMetadata = true, onFocused, onClick, onLongClick, modifier, + ) +} + @Composable fun LandscapeMediaCard( item: BaseItem, @@ -1138,12 +1422,18 @@ private fun MediaCard( modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), ) } + if (item.membyAiringToday) { + MediaBadge( + "AIRING TODAY", + modifier = Modifier.align(Alignment.TopStart).padding(8.dp), + ) + } } Text( item.name, - color = if (focused) Color.White else Color(0xFFD1D5D8), + color = if (focused) Color.White else Color(0xFFE1E5E8), fontSize = 14.sp, - fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium, + fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), @@ -1153,6 +1443,7 @@ private fun MediaCard( cardSubtitle(item, showProgress, position), color = QuietText, fontSize = 12.sp, + fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), @@ -1190,14 +1481,14 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) { fun FocusScaleContainer( onFocused: () -> Unit, onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, contentDescription: String, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, content: @Composable BoxScope.(focused: Boolean) -> Unit, ) { var focused by remember { mutableStateOf(false) } var remoteLongPressHandled by remember { mutableStateOf(false) } - val scale by animateFloatAsState( + val scale = animateFloatAsState( targetValue = if (focused) 1.025f else 1f, animationSpec = tween(95), label = "media-card-focus", @@ -1205,16 +1496,13 @@ fun FocusScaleContainer( Box( modifier = modifier .zIndex(if (focused) 1f else 0f) - .then( - if (focused || scale != 1f) { - Modifier.graphicsLayer { - scaleX = scale - scaleY = scale - } - } else { - Modifier - }, - ) + // Always a graphicsLayer, and the scale read inside it. Branching on + // `scale != 1f` meant reading the animated value during composition, so + // every card recomposed on each frame of its own focus animation. + .graphicsLayer { + scaleX = scale.value + scaleY = scale.value + } .onFocusChanged { focused = it.isFocused if (it.isFocused) onFocused() @@ -1309,6 +1597,7 @@ private const val BACKDROP_SETTLE_DELAY_MS = 240L private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when { showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}" + !item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today" item.isEpisode -> item.seriesName ?: "Up next" item.productionYear != null && item.runtimeMinutes != null -> diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 5b35c9a..26731ce 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.HomeCache +import com.ponzischeme89.memby.data.HomeSnapshot import com.ponzischeme89.memby.data.analytics.RowAnalytics import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.isMaintenanceError @@ -83,12 +84,21 @@ data class HomeUiState( } } +data class ForYouUiState( + val rows: List = emptyList(), + val availableMinutes: Int = 0, + val loading: Boolean = false, + val error: String? = null, +) + class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private val refreshMutex = Mutex() private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome())) val state: StateFlow = _state.asStateFlow() private val _focusedItem = MutableStateFlow(initialFocusedItem(_state.value)) val focusedItem: StateFlow = _focusedItem.asStateFlow() + private val _forYou = MutableStateFlow(ForYouUiState()) + val forYou: StateFlow = _forYou.asStateFlow() private var metadataJob: Job? = null private val metadataCache = object : LinkedHashMap(32, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 32 @@ -163,6 +173,31 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { repository.reportRowEvents(analytics.drain()) } + fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) { + val minutes = availableMinutes.coerceIn(0, 360) + _focusedItem.value = null + _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) } + viewModelScope.launch(Dispatchers.IO) { + runCatching { repository.getForYou(minutes) } + .onSuccess { rows -> + _forYou.value = ForYouUiState( + rows = rows, + availableMinutes = minutes, + loading = false, + ) + rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem) + } + .onFailure { + _forYou.update { + it.copy( + loading = false, + error = "For You is temporarily unavailable", + ) + } + } + } + } + fun refreshAll() { viewModelScope.launch(Dispatchers.IO) { refreshMutex.withLock { @@ -189,18 +224,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private suspend fun loadBatchHome() { runCatching { repository.getHome() } .onSuccess { home -> + val taggedHome = home.withAiringTodayTags() _state.update { current -> current.copy( - continueWatching = home.continueWatching, - nextUp = home.nextUp, - favorites = home.favorites, - latestMovies = home.latestMovies, + continueWatching = taggedHome.continueWatching, + nextUp = taggedHome.nextUp, + favorites = taggedHome.favorites, + latestMovies = taggedHome.latestMovies, // Recommendation rows are built in the background by the gateway, // so an early response can arrive without them. Keeping the rows // we already had stops the strip flickering out and back in. - rows = home.rows.ifEmpty { current.rows }, + rows = taggedHome.rows.ifEmpty { current.rows }, loading = emptySet(), - hasRefreshError = home.partial, + hasRefreshError = taggedHome.partial, statusMessage = null, // A successful response is the only thing that clears the // maintenance screen, so a retry that fails keeps it up. @@ -210,6 +246,10 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { if (_focusedItem.value == null) { initialFocusedItem(_state.value)?.let(::focusItem) } + // Home may have been cached just before the background recommendation + // build completed. Pull the dedicated endpoint after the fast home draw + // so personalized Shows shelves appear on this visit, not a minute later. + refreshRecommendationRows() } .onFailure { error -> val maintenance = isMaintenanceError(error) @@ -224,13 +264,30 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } + private suspend fun refreshRecommendationRows() { + val fresh = runCatching { repository.getRecommendations() }.getOrNull() ?: return + _state.update { state -> + val airingTodayKeys = state.rows.airingTodayShowKeys() + val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys) + val fixedRows = state.rows.filterNot { row -> + row.id == "recommended" || + row.id.startsWith("similar:") || + row.id.startsWith("curated:") + } + state.copy(rows = fixedRows + taggedFresh) + } + } + /** * Updates local metadata immediately, then enriches it only after focus settles. * Cancelling the previous job prevents stale responses from winning rapid D-pad navigation. */ fun focusItem(item: BaseItem) { val cached = synchronized(metadataCache) { metadataCache[item.id] } - _focusedItem.value = cached ?: item + val focused = (cached ?: item).copy( + membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true, + ) + _focusedItem.value = focused metadataJob?.cancel() metadataJob = viewModelScope.launch(Dispatchers.IO) { delay(FOCUS_METADATA_DEBOUNCE_MS) @@ -246,9 +303,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { val details = runCatching { repository.getItemDetails(item.id) }.getOrNull() ?: return@launch - synchronized(metadataCache) { metadataCache[item.id] = details } + val taggedDetails = details.copy( + membyAiringToday = focused.membyAiringToday, + membyRecommendationReason = focused.membyRecommendationReason, + membyCompatibility = focused.membyCompatibility, + ) + synchronized(metadataCache) { metadataCache[item.id] = taggedDetails } if (_focusedItem.value?.id == item.id) { - _focusedItem.value = details + _focusedItem.value = taggedDetails } } } @@ -411,6 +473,40 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } +internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot { + val airingTodayKeys = rows.airingTodayShowKeys() + if (airingTodayKeys.isEmpty()) return this + return copy( + rows = rows.withAiringTodayRowTags(airingTodayKeys), + continueWatching = continueWatching.withAiringTodayItemTags(airingTodayKeys), + nextUp = nextUp.withAiringTodayItemTags(airingTodayKeys), + favorites = favorites.withAiringTodayItemTags(airingTodayKeys), + latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys), + ) +} + +private fun List.airingTodayShowKeys(): Set = + firstOrNull { it.id == "sonarr-airing-today" } + ?.items + .orEmpty() + .mapTo(mutableSetOf()) { it.name.showMatchKey() } + .filterTo(mutableSetOf(), String::isNotEmpty) + +private fun List.withAiringTodayRowTags(keys: Set): List = + map { row -> row.copy(items = row.items.withAiringTodayItemTags(keys)) } + +private fun List.withAiringTodayItemTags(keys: Set): List = + map { item -> + if (!item.isSonarrSchedule && item.isSeries && item.name.showMatchKey() in keys) { + item.copy(membyAiringToday = true) + } else { + item + } + } + +private fun String.showMatchKey(): String = + lowercase().filter(Char::isLetterOrDigit) + class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index b3c2fc9..9ea6ea2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -8,8 +8,10 @@ import android.provider.Settings as AndroidSettings import android.text.format.DateFormat import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -37,6 +39,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -64,11 +67,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer @@ -81,9 +90,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex @@ -100,6 +111,7 @@ import com.ponzischeme89.memby.data.DeviceLimitException import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.performance.PerformanceMonitor +import com.ponzischeme89.memby.ui.search.SearchScreen import com.ponzischeme89.memby.ui.settings.SettingsSheet import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity import com.ponzischeme89.memby.ui.theme.MembyTheme @@ -136,9 +148,18 @@ private fun AppRoot(onCloseSettings: () -> Unit) { var settings by remember { mutableStateOf(null) } var addingProfile by rememberSaveable { mutableStateOf(false) } var startingFirstRun by rememberSaveable { mutableStateOf(false) } + var validatedToken by remember { mutableStateOf(null) } LaunchedEffect(repo) { repo.settingsFlow.collect { settings = it } } + LaunchedEffect(settings?.token) { + val token = settings?.token?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect + if (validatedToken == token) return@LaunchedEffect + validatedToken = token + if (!repo.validateSession()) { + repo.invalidateSession() + } + } Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11))) { val loaded = settings @@ -232,12 +253,20 @@ private fun MembyLoadingScreen() { .clip(CircleShape) .background(Color.White.copy(alpha = 0.10f)), ) { + // Drawn, not measured: `fillMaxWidth(glow)` would recompose this screen + // every frame — during cold start, which is the one moment the device has + // nothing to spare. Box( Modifier - .fillMaxWidth(glow) + .fillMaxWidth() .height(2.dp) - .clip(CircleShape) - .background(Color(0xFF52B54B)), + .drawBehind { + drawRoundRect( + color = Color(0xFF52B54B), + size = Size(size.width * glow, size.height), + cornerRadius = CornerRadius(size.height / 2f), + ) + }, ) } } @@ -316,7 +345,7 @@ private fun FirstRunScreen(onGetStarted: () -> Unit) { .fillMaxSize() .padding(horizontal = 88.dp, vertical = 64.dp) .graphicsLayer { alpha = contentAlpha } - .offset(y = contentOffset), + .offset { IntOffset(x = 0, y = contentOffset.roundToPx()) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -396,32 +425,62 @@ private fun SetupScreen( val context = LocalContext.current val scope = rememberCoroutineScope() - val suggestedName = remember(context) { suggestedDeviceName(context) } - var deviceName by rememberSaveable { - mutableStateOf( - ServiceLocator.settings.current?.deviceName - ?.takeIf { it.isNotBlank() } - ?: suggestedName, - ) + val automaticDeviceName = remember(context) { + ServiceLocator.settings.current?.deviceName + ?.takeIf { it.isNotBlank() } + ?: suggestedDeviceName(context) } var username by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } var connecting by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - var maxClientsPerUser by remember { mutableStateOf(null) } - val deviceNameFocus = remember { FocusRequester() } - LaunchedEffect(repo) { - maxClientsPerUser = runCatching { repo.authPolicy()?.maxClientsPerUser } - .getOrNull() - ?.takeIf { it > 0 } - } + val usernameFocus = remember { FocusRequester() } + val passwordFocus = remember { FocusRequester() } LaunchedEffect(Unit) { kotlinx.coroutines.delay(100L) - runCatching { deviceNameFocus.requestFocus() } + runCatching { usernameFocus.requestFocus() } } if (onCancel != null) BackHandler(onBack = onCancel) + val submit: () -> Unit = { + if (username.isBlank()) { + error = "Username is required." + } else if (!connecting) { + connecting = true + error = null + scope.launch { + runCatching { + repo.authenticate( + serverUrl = "", + username = username.trim(), + password = password, + deviceName = automaticDeviceName, + ) + } + .onSuccess { authenticatedUsername -> + Toast.makeText( + context, + loginWelcomeMessage(authenticatedUsername), + Toast.LENGTH_LONG, + ).show() + onSignedIn() + } + .onFailure { failure -> + error = when (failure) { + is DeviceLimitException -> + "Device limit reached: this account is using " + + "${failure.activeClients} of ${failure.maxClients} devices. " + + "Sign out on another TV, then try again." + else -> + "Memby couldn't sign in. Check the username and password and try again." + } + } + connecting = false + } + } + } + Box( modifier = Modifier .fillMaxSize() @@ -440,6 +499,10 @@ private fun SetupScreen( .clip(RoundedCornerShape(22.dp)) .background(Color(0xF2161C20)) .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(22.dp)) + // The form is taller than the safe content area on some Android TVs. + // A scroll container also lets Compose bring the focused text field + // above the on-screen keyboard instead of clipping username/password. + .verticalScroll(rememberScrollState()) .padding(horizontal = 44.dp, vertical = 38.dp), verticalArrangement = Arrangement.spacedBy(18.dp), ) { @@ -456,13 +519,13 @@ private fun SetupScreen( Text( if (onCancel != null) "Add another viewer" else "Welcome to Memby", color = Color.White, - fontSize = 32.sp, + fontSize = 26.sp, fontWeight = FontWeight.Bold, ) Text( "Sign in with an Emby account", color = Color(0xFF9EA9B1), - fontSize = 16.sp, + fontSize = 14.sp, ) } } @@ -471,83 +534,33 @@ private fun SetupScreen( text = if (onCancel != null) { "Their account will be saved as another profile on this TV." } else { - "Enter your username and password to start watching." + "Enter your Emby credentials to start watching." }, color = Color(0xFFBAC2C8), fontSize = 17.sp, ) - TvTextField( - label = "Name this TV", - value = deviceName, - onValueChange = { deviceName = it.take(80); error = null }, - modifier = Modifier.focusRequester(deviceNameFocus), - ) - Text( - "We suggested this from your TV. A room name such as “Dad’s TV” makes it easier to recognise later.", - color = Color(0xFF89959D), - fontSize = 14.sp, - ) - TvTextField( label = "Username", value = username, onValueChange = { username = it; error = null }, - modifier = Modifier, + focusRequester = usernameFocus, + onNext = { passwordFocus.requestFocus() }, ) TvTextField( label = "Password", value = password, onValueChange = { password = it; error = null }, isPassword = true, + focusRequester = passwordFocus, + onDone = submit, ) - maxClientsPerUser?.let { maximum -> - Text( - "Each Emby account can be signed in on up to $maximum Memby devices.", - color = Color(0xFF76C970), - fontSize = 14.sp, - ) - } - error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 16.sp) } Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { Button( - onClick = { - if (deviceName.isBlank()) { - error = "Give this TV a name." - return@Button - } - if (username.isBlank()) { - error = "Username is required." - return@Button - } - connecting = true - error = null - scope.launch { - runCatching { - repo.authenticate( - serverUrl = "", - username = username.trim(), - password = password, - deviceName = deviceName.trim(), - ) - } - .onSuccess { onSignedIn() } - .onFailure { failure -> - error = when (failure) { - is DeviceLimitException -> - "Device limit reached: this account is using " + - "${failure.activeClients} of ${failure.maxClients} devices. " + - "Sign out on another TV, then try again." - else -> - "Memby couldn't sign in. Check the username and password and try again." - } - } - connecting = false - } - }, + onClick = submit, enabled = !connecting, ) { Text(if (connecting) "Signing in…" else "Sign in") @@ -734,9 +747,25 @@ private fun HomeScreen( val context = LocalContext.current val scope = rememberCoroutineScope() val factory = remember(repo) { HomeViewModelFactory(repo) } - val homeViewModel: HomeViewModel = viewModel(factory = factory) + // HomeViewModel owns user-scoped rows, recommendation state, metadata caches and + // in-flight requests. The Activity's default ViewModelStore outlives a Compose key, + // so using it here leaks the previous profile's state across a profile switch. + val profileViewModelOwner = remember( + settings.activeProfileId, + settings.serverUrl, + settings.userId, + ) { ProfileViewModelStoreOwner() } + DisposableEffect(profileViewModelOwner) { + onDispose { profileViewModelOwner.clear() } + } + val homeViewModel: HomeViewModel = viewModel( + viewModelStoreOwner = profileViewModelOwner, + factory = factory, + ) val homeState by homeViewModel.state.collectAsStateWithLifecycle() + val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() + val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() var showSettings by remember { mutableStateOf(false) } var showProfiles by remember { mutableStateOf(false) } @@ -748,10 +777,33 @@ private fun HomeScreen( var launchingItem by remember { mutableStateOf(null) } var returnRowId by rememberSaveable { mutableStateOf(null) } var returnItemId by rememberSaveable { mutableStateOf(null) } - val navigationFocusRequester = remember { FocusRequester() } + var recentSearches by remember { mutableStateOf>(emptyList()) } + var initialSearchQuery by rememberSaveable { mutableStateOf(null) } + var showForYouNudge by rememberSaveable(settings.activeProfileId) { + mutableStateOf(!settings.hasOpenedForYou) + } + // Each destination owns a stable requester. Moving one requester between rail items + // can leave a key event pointing at the previously selected item for a frame. + val navigationFocusRequesters = remember { + BrowseDestination.entries.associateWith { FocusRequester() } + } + val navigationFocusRequester = navigationFocusRequesters.getValue(selectedDestination) val contentFocusRequester = remember { FocusRequester() } val cardReturnFocusRequester = remember { FocusRequester() } var initialFocusRequested by remember { mutableStateOf(false) } + val playbackLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { + // PlayerActivity has finished and this activity owns the window again. Compose + // needs one frame to reattach the saved card's focus node before it can receive + // focus, especially when playback progress refreshed the row behind the player. + scope.launch { + kotlinx.coroutines.delay(32L) + if (returnRowId != null && returnItemId != null) { + runCatching { cardReturnFocusRequester.requestFocus() } + } + } + } LaunchedEffect(liveMaintenance) { if (liveMaintenance != null) { @@ -774,7 +826,7 @@ private fun HomeScreen( try { runCatching { repo.resolvePlayable(item) } .onSuccess { playable -> - context.startActivity( + playbackLauncher.launch( PlayerActivity.intent( context = context, itemId = playable.itemId, @@ -782,6 +834,10 @@ private fun HomeScreen( title = playable.title, resumePositionMs = playable.resumePositionMs, logoUrl = playable.logoUrl, + subtitles = playable.subtitles, + mediaSourceId = playable.mediaSourceId, + playSessionId = playable.playSessionId, + playMethod = playable.playMethod, requestStartedAtMs = playbackRequestedAtMs, ), ) @@ -795,16 +851,48 @@ private fun HomeScreen( } } - val rows = remember(homeState, selectedDestination, settings.homeSections, settings.showHomeCardMetadata) { - homeRowsFor(selectedDestination, homeState, settings) + val rows = remember( + homeState, + forYouState, + selectedDestination, + settings.homeSections, + settings.showHomeCardMetadata, + ) { + if (selectedDestination == BrowseDestination.FOR_YOU) { + forYouBrowseRows(forYouState) + } else { + homeRowsFor(selectedDestination, homeState, settings) + } + } + LaunchedEffect(selectedDestination) { + if (selectedDestination == BrowseDestination.FAVORITES) { + recentSearches = repo.getRecentSearches() + } + if (selectedDestination == BrowseDestination.FOR_YOU && forYouState.rows.isEmpty()) { + homeViewModel.loadForYou(settings.forYouMinutes) + } + if (selectedDestination == BrowseDestination.FOR_YOU && !settings.hasOpenedForYou) { + repo.markForYouOpened() + } + } + LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) { + if ( + showForYouNudge && + selectedDestination == BrowseDestination.HOME && + !settings.hasOpenedForYou + ) { + kotlinx.coroutines.delay(5_000L) + showForYouNudge = false + } } LaunchedEffect(selectedDestination, rows.firstOrNull()?.items?.firstOrNull()?.id) { val firstItem = rows.firstNotNullOfOrNull { it.items.firstOrNull() } firstItem?.let(homeViewModel::focusItem) if (firstItem != null && !initialFocusRequested) { kotlinx.coroutines.delay(16L) - contentFocusRequester.requestFocus() - initialFocusRequested = true + if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) { + initialFocusRequested = true + } } } @@ -834,13 +922,21 @@ private fun HomeScreen( selected = selectedDestination, expanded = navigationExpanded, navigationFocusRequester = navigationFocusRequester, - contentFocusRequester = contentFocusRequester, onRailFocusChanged = { navigationExpanded = it }, onDestinationSelected = { destination -> + navigationExpanded = false when (destination) { BrowseDestination.SETTINGS -> showSettings = true BrowseDestination.PROFILES -> showProfiles = true - else -> selectedDestination = destination + else -> { + selectedDestination = destination + scope.launch { + // Let the destination compose and attach its entry target + // before transferring focus out of the rail. + kotlinx.coroutines.delay(16L) + runCatching { contentFocusRequester.requestFocus() } + } + } } }, ) @@ -848,7 +944,7 @@ private fun HomeScreen( modifier = Modifier .weight(1f) .fillMaxSize() - .offset(x = contentShift), + .offset { IntOffset(x = contentShift.roundToPx(), y = 0) }, ) { val maintenanceMessage = liveMaintenance?.message ?: homeState.maintenanceMessage if (maintenanceMessage != null) { @@ -867,6 +963,46 @@ private fun HomeScreen( return@BoxWithConstraints } + if (selectedDestination == BrowseDestination.SEARCH) { + // Remembered, or this list is a fresh instance on every recomposition + // and the screen re-derives its genre chips each time. + val discovery = remember(homeState.rows, homeState.latestMovies, homeState.continueWatching) { + homeState.rows.flatMap { it.items } + .ifEmpty { homeState.watchingAndNextUp + homeState.latestMovies } + .distinctBy(BaseItem::id) + } + // Its own pane rather than a row list: the keyboard and the results + // need the whole content area. The rail stays beside it, and results + // open the same details overlay the rows do. + SearchScreen( + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + // Already in memory from the home response — the empty state + // costs nothing to fill. + discoveryItems = discovery, + returnFocusItemId = returnItemId.takeIf { returnRowId == SEARCH_ROW_ID }, + returnFocusRequester = cardReturnFocusRequester, + initialQuery = initialSearchQuery, + onInitialQueryConsumed = { initialSearchQuery = null }, + onItemFocused = homeViewModel::focusItem, + onItemSelected = { item -> + returnRowId = SEARCH_ROW_ID + returnItemId = item.id + homeViewModel.focusItem(item) + if (item.membyPlayable) detailsItem = item + }, + onContentFocused = { navigationExpanded = false }, + onExit = { + selectedDestination = BrowseDestination.HOME + scope.launch { + kotlinx.coroutines.delay(16L) + runCatching { contentFocusRequester.requestFocus() } + } + }, + ) + return@BoxWithConstraints + } + FocusedHomeBackdrop(homeViewModel) val metadataHeight = (maxHeight * 0.42f).coerceIn(220.dp, 300.dp) val contentWidth = maxWidth @@ -879,7 +1015,6 @@ private fun HomeScreen( FocusedHomeMetadata( homeViewModel = homeViewModel, sectionLabel = selectedDestination.label, - contentFocusRequester = contentFocusRequester, navigationFocusRequester = navigationFocusRequester, onPlay = playItem, onContentFocused = { navigationExpanded = false }, @@ -903,10 +1038,42 @@ private fun HomeScreen( } LazyColumn( state = verticalState, - modifier = Modifier.weight(1f).fillMaxWidth(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 32.dp), + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(bottom = 32.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + if ( + selectedDestination == BrowseDestination.FAVORITES && + recentSearches.isNotEmpty() + ) { + item(key = "recent-searches", contentType = "recent-searches") { + RecentSearchesRow( + queries = recentSearches, + navigationFocusRequester = navigationFocusRequester, + onContentFocused = { navigationExpanded = false }, + onQuerySelected = { query -> + initialSearchQuery = query + selectedDestination = BrowseDestination.SEARCH + }, + ) + } + } + if (selectedDestination == BrowseDestination.FOR_YOU) { + item(key = "for-you-time", contentType = "for-you-time") { + ForYouTimeBudget( + selectedMinutes = forYouState.availableMinutes, + loading = forYouState.loading, + error = forYouState.error, + onSelected = { minutes -> + homeViewModel.loadForYou(minutes) + scope.launch { repo.setForYouMinutes(minutes) } + }, + ) + } + } items( items = rows, key = { it.id }, @@ -921,7 +1088,9 @@ private fun HomeScreen( row = row, availableWidth = contentWidth, navigationFocusRequester = navigationFocusRequester, - contentEntryFocusRequester = null, + contentEntryFocusRequester = contentFocusRequester.takeIf { + row.id == rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id + }, returnFocusItemId = returnItemId.takeIf { returnRowId == row.id }, returnFocusRequester = cardReturnFocusRequester, onContentFocused = { navigationExpanded = false }, @@ -948,6 +1117,23 @@ private fun HomeScreen( } } } + androidx.compose.animation.AnimatedVisibility( + visible = compatibilityNotice != null, + modifier = Modifier.align(Alignment.TopCenter), + enter = androidx.compose.animation.fadeIn(tween(180)), + exit = androidx.compose.animation.fadeOut(tween(120)), + ) { + Text( + text = compatibilityNotice?.message.orEmpty(), + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFD6403A)) + .padding(horizontal = 36.dp, vertical = 12.dp), + ) + } HomeClock( modifier = Modifier .align(Alignment.BottomEnd) @@ -984,7 +1170,7 @@ private fun HomeScreen( detailsItem?.let { selected -> BackHandler { detailsItem = null - cardReturnFocusRequester.requestFocus() + runCatching { cardReturnFocusRequester.requestFocus() } } FocusedDetailsOverlay( homeViewModel = homeViewModel, @@ -997,14 +1183,14 @@ private fun HomeScreen( onTogglePlayed = homeViewModel::setPlayed, onClose = { detailsItem = null - cardReturnFocusRequester.requestFocus() + runCatching { cardReturnFocusRequester.requestFocus() } }, ) } quickMenuItem?.let { selected -> BackHandler { quickMenuItem = null - cardReturnFocusRequester.requestFocus() + runCatching { cardReturnFocusRequester.requestFocus() } } FocusedQuickActionsOverlay( homeViewModel = homeViewModel, @@ -1013,7 +1199,7 @@ private fun HomeScreen( onSetPlayed = homeViewModel::setPlayed, onClose = { quickMenuItem = null - cardReturnFocusRequester.requestFocus() + runCatching { cardReturnFocusRequester.requestFocus() } }, ) } @@ -1023,6 +1209,26 @@ private fun HomeScreen( modifier = Modifier.fillMaxSize().zIndex(9f), ) } + ForYouNudgeBanner( + visible = showForYouNudge && + selectedDestination == BrowseDestination.HOME && + !settings.hasOpenedForYou && + liveMaintenance == null && + homeState.update?.isMandatory != true, + username = settings.username, + modifier = Modifier.align(Alignment.TopCenter), + ) + // News about the library, not about the app: it sits above the rows but is + // suppressed whenever something more important owns the screen. + ServiceAlertBanner( + // The alert itself is collected inside the banner, so an arriving one does + // not recompose this whole function. Only the suppression conditions — both + // already read here for other reasons — cross the boundary. + suppressed = liveMaintenance != null || homeState.update?.isMandatory == true, + // Flush to the top edge and spanning the rail: for its few seconds this is + // the top layer of the screen, the way a broadcast notice is. + modifier = Modifier.align(Alignment.TopCenter), + ) // Last in the Box, so it draws over everything — including the rail and any // overlay that happened to be open when the check came back. homeState.update?.takeIf { liveMaintenance == null }?.let { update -> @@ -1080,6 +1286,78 @@ private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) } } +/** Row id under which the search grid records its return-focus target. */ +private const val SEARCH_ROW_ID = "search-results" + +@Composable +private fun RecentSearchesRow( + queries: List, + navigationFocusRequester: FocusRequester, + onContentFocused: () -> Unit, + onQuerySelected: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Recent searches", + color = Color(0xFFF1F3F4), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(10.dp)) + Text( + "LAST 30 DAYS", + color = Color(0xFFAEB7BF), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + } + LazyRow( + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 36.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + items(queries, key = { it.lowercase() }) { query -> + val first = query == queries.first() + FocusScaleContainer( + onFocused = onContentFocused, + onClick = { onQuerySelected(query) }, + contentDescription = "Search again for $query", + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .then( + if (first) { + Modifier.focusProperties { left = navigationFocusRequester } + } else { + Modifier + }, + ), + ) { focused -> + Text( + query, + color = if (focused) Color(0xFF06240A) else Color(0xFFE8EDF1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + modifier = Modifier + .background( + if (focused) Color(0xFF52B54B) else Color(0xFF1A2129), + ) + .padding(horizontal = 17.dp, vertical = 10.dp), + ) + } + } + } + } +} + +internal fun loginWelcomeMessage(username: String): String { + val name = username.trim().ifBlank { "there" } + return "You're now logged in as $name. Welcome to Memby!" +} + @Composable private fun HomeClock(modifier: Modifier = Modifier) { val context = LocalContext.current @@ -1164,7 +1442,6 @@ private fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { private fun FocusedHomeMetadata( homeViewModel: HomeViewModel, sectionLabel: String, - contentFocusRequester: FocusRequester, navigationFocusRequester: FocusRequester, onPlay: (BaseItem) -> Unit, onContentFocused: () -> Unit, @@ -1176,7 +1453,6 @@ private fun FocusedHomeMetadata( item = focusedItem, loading = homeState.loading.isNotEmpty(), sectionLabel = sectionLabel, - contentFocusRequester = contentFocusRequester, navigationFocusRequester = navigationFocusRequester, onPlay = onPlay, onContentFocused = onContentFocused, @@ -1194,13 +1470,23 @@ private fun FocusedDetailsOverlay( onClose: () -> Unit, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - MediaDetailsOverlay( - item = focusedItem?.takeIf { it.id == selected.id } ?: selected, - onPlay = onPlay, - onToggleFavorite = onToggleFavorite, - onTogglePlayed = onTogglePlayed, - onClose = onClose, - ) + val item = focusedItem?.takeIf { it.id == selected.id } ?: selected + if (item.isSeries) { + SeriesDetailsOverlay( + item = item, + onPlay = onPlay, + onToggleFavorite = onToggleFavorite, + onClose = onClose, + ) + } else { + MediaDetailsOverlay( + item = item, + onPlay = onPlay, + onToggleFavorite = onToggleFavorite, + onTogglePlayed = onTogglePlayed, + onClose = onClose, + ) + } } @Composable @@ -1274,7 +1560,7 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List listOf( - latestMovies, - favorites.copy( - id = "favourite-movies", - title = "Favourite Movies", - items = state.favorites.filter(BaseItem::isMovie), - ), - ) + BrowseDestination.MOVIES -> serverHomeRows(state, settings) + // Movie discovery is authored by the server from this profile's genre and + // studio affinity. Broad latest/library dumps belong on Home, not here. + .filter { row -> + row.kind == MediaRowKind.MOVIES && + row.id.startsWith("curated:movies:") + } + .distinctBy(HomeBrowseRow::id) + listOf( + favorites.copy( + id = "favourite-movies", + title = "Favourite Movies", + items = state.favorites.filter(BaseItem::isMovie), + ), + ) BrowseDestination.SHOWS -> listOf( nextUpRow, continueRow.copy( @@ -1353,13 +1645,153 @@ private fun homeRowsFor( title = "Favourite Shows", items = state.favorites.filter { it.isSeries || it.isEpisode }, ), - ) + ) + serverHomeRows(state, settings) + // These shelves are authored and ordered by the per-user recommendation + // engine. Keep their server order: it is the user's affinity ranking. + .filter { row -> + row.kind == MediaRowKind.SHOWS && row.id.startsWith("curated:") + } + .distinctBy(HomeBrowseRow::id) BrowseDestination.FAVORITES -> listOf(favorites) + BrowseDestination.FOR_YOU -> emptyList() + // Search draws its own pane; the rail destinations that open an overlay have no + // rows of their own either. + BrowseDestination.SEARCH -> emptyList() BrowseDestination.PROFILES -> emptyList() BrowseDestination.SETTINGS -> emptyList() } } +internal fun forYouBrowseRows( + state: ForYouUiState, +): List { + if (state.rows.isEmpty()) { + return listOf( + HomeBrowseRow( + id = "for-you:picks", + title = "Top picks for you", + items = emptyList(), + kind = MediaRowKind.MOVIES, + loading = state.loading, + emptyMessage = state.error ?: "Watch and browse a little more to shape your picks", + showSecondaryMetadata = true, + ), + ) + } + return state.rows.map { row -> + HomeBrowseRow( + id = row.id, + title = row.title, + items = row.items, + kind = MediaRowKind.MOVIES, + loading = state.loading && row.items.isEmpty(), + emptyMessage = state.error ?: "No picks fit this time window yet", + // Reasons are the primary secondary metadata in this destination. + showSecondaryMetadata = true, + ) + } +} + +@Composable +private fun ForYouTimeBudget( + selectedMinutes: Int, + loading: Boolean, + error: String?, + onSelected: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "How much time do you have?", + color = Color(0xFFD0D6DB), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + listOf(30 to "30 min", 60 to "1 hour", 120 to "2 hours", 0 to "Any length") + .forEach { (minutes, label) -> + Button( + onClick = { if (!loading && minutes != selectedMinutes) onSelected(minutes) }, + ) { + Text(if (minutes == selectedMinutes) "✓ $label" else label) + } + } + } + error?.let { + Text(it, color = Color(0xFFFFB454), fontSize = 13.sp) + } + } +} + +@Composable +private fun ForYouNudgeBanner( + visible: Boolean, + username: String?, + modifier: Modifier = Modifier, +) { + val name = friendlyProfileName(username) + androidx.compose.animation.AnimatedVisibility( + visible = visible, + enter = androidx.compose.animation.fadeIn(tween(220)), + exit = androidx.compose.animation.fadeOut(tween(260)), + modifier = modifier.zIndex(7f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(92.dp) + .background( + Brush.horizontalGradient( + 0f to Color(0xFF0D1512), + 0.62f to Color(0xF20D1512), + 1f to Color(0xE0121A20), + ), + ) + // Keep copy inside television overscan and clear of the collapsed rail. + .padding(start = 72.dp, end = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(Color(0xFF52B54B)), + ) + Spacer(Modifier.width(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + "FOR YOU", + color = Color(0xFF79D672), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + ) + Text( + text = if (name == null) { + "Your personalised picks are ready" + } else { + "$name, your personalised picks are ready" + }, + color = Color(0xFFF3F6F4), + fontSize = 21.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "Open For You from the menu and choose how much time you have.", + color = Color(0xFFC7D0CB), + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + /** * Turns Emby account-style usernames into a friendlier home-screen name. * @@ -1493,7 +1925,7 @@ private fun HomeRow( state = rowState, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 48.dp, vertical = 7.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth().focusGroup().focusRestorer(), + modifier = Modifier.fillMaxWidth().focusGroup(), ) { items(items, key = { it.id }, contentType = { "content-card" }) { item -> ContentCard( @@ -1517,9 +1949,15 @@ private fun HomeRowSkeleton() { animationSpec = infiniteRepeatable(tween(850), RepeatMode.Reverse), label = "home-loading-alpha", ) + // Several of these are on screen at once while rows load; reading the pulse in the + // draw lambda keeps that from being a per-frame recomposition each. Box( - Modifier.width(300.dp).height(169.dp) - .background(Color(0xFF30353A).copy(alpha = alpha), RoundedCornerShape(8.dp)), + Modifier.width(300.dp).height(169.dp).drawBehind { + drawRoundRect( + color = Color(0xFF30353A).copy(alpha = alpha), + cornerRadius = CornerRadius(8.dp.toPx()), + ) + }, ) } @@ -1578,13 +2016,26 @@ private fun ContentCard( Modifier .width(cardWidth) .height(cardHeight) - .border( - width = 3.dp, - color = Color.White.copy(alpha = focusAlpha), - shape = RoundedCornerShape(8.dp), - ) .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFF171A1D)), + .background(Color(0xFF171A1D)) + // The focus ring is drawn rather than set as a border colour: read + // an animated alpha in the modifier chain and every D-pad move + // recomposes this whole card — artwork, badges and all — sixty times + // a second. Read inside the draw lambda and it is a repaint. + .drawWithContent { + drawContent() + val alpha = focusAlpha + if (alpha > 0.01f) { + val stroke = 3.dp.toPx() + drawRoundRect( + color = Color.White.copy(alpha = alpha), + topLeft = Offset(stroke / 2f, stroke / 2f), + size = Size(size.width - stroke, size.height - stroke), + cornerRadius = CornerRadius(8.dp.toPx()), + style = Stroke(width = stroke), + ) + } + }, ) { artwork?.let { AsyncImage( @@ -1861,25 +2312,60 @@ private fun TvTextField( modifier: Modifier = Modifier, isPassword: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, + focusRequester: FocusRequester? = null, + onNext: (() -> Unit)? = null, + onDone: (() -> Unit)? = null, ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(label, color = Color(0xFF9AA3AC), fontSize = 14.sp) + var focused by remember { mutableStateOf(false) } + val borderColor by animateColorAsState( + targetValue = if (focused) Color(0xFF69CD61) else Color(0xFF3A424B), + animationSpec = tween(120), + label = "login-field-border", + ) + val backgroundColor by animateColorAsState( + targetValue = if (focused) Color(0xFF243129) else Color(0xFF161B21), + animationSpec = tween(120), + label = "login-field-background", + ) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = if (focused) "$label — selected" else label, + color = if (focused) Color(0xFF8DE185) else Color(0xFF9AA3AC), + fontSize = 15.sp, + fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, + ) Box( modifier = Modifier .fillMaxWidth() - .border(1.dp, Color(0xFF3A424B), RoundedCornerShape(8.dp)) - .background(Color(0xFF161B21), RoundedCornerShape(8.dp)) - .padding(horizontal = 16.dp, vertical = 14.dp), + .border(if (focused) 3.dp else 1.dp, borderColor, RoundedCornerShape(10.dp)) + .background(backgroundColor, RoundedCornerShape(10.dp)) + .padding(horizontal = 18.dp, vertical = 16.dp), ) { BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, textStyle = TextStyle(color = Color.White, fontSize = 20.sp), - cursorBrush = SolidColor(Color.White), + cursorBrush = SolidColor(Color(0xFF69CD61)), visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else keyboardType), - modifier = modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = if (isPassword) KeyboardType.Password else keyboardType, + imeAction = if (onNext != null) ImeAction.Next else ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onNext = { onNext?.invoke() }, + onDone = { onDone?.invoke() }, + ), + modifier = Modifier + .fillMaxWidth() + .then( + if (focusRequester != null) Modifier.focusRequester(focusRequester) + else Modifier, + ) + .onFocusChanged { focused = it.isFocused }, ) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt index 77829d8..a2dca09 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -58,7 +59,7 @@ import kotlinx.coroutines.delay private val MaintenanceAccent = Color(0xFF52B54B) private val MaintenanceTitle = Color(0xFFF2F5F7) private val MaintenanceBody = Color(0xFFAEB7BF) -private val MaintenanceFaint = Color(0xFF7E888F) +private val MaintenanceFaint = Color(0xFFA2ADB5) /** How long between automatic retries while the gateway is down. */ private const val RETRY_SECONDS = 30 @@ -117,7 +118,7 @@ fun MaintenanceScreen( label = "maintenance-entrance", ) - var secondsLeft by remember { mutableStateOf(RETRY_SECONDS) } + var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) } LaunchedEffect(message) { // Restarts whenever the message changes, so a failed retry resets the clock. secondsLeft = RETRY_SECONDS @@ -194,6 +195,21 @@ fun MaintenanceScreen( } } +// The animations here are infinite, so a preview catches them mid-phase. That is fine for +// checking layout and colour; the motion itself only reads correctly on a device. +@TvPreview +@Composable +private fun MaintenanceScreenPreview() { + PreviewSurface { + MaintenanceScreen( + message = "Back after dinner — the library is being reorganised.", + contentFocusRequester = remember { FocusRequester() }, + navigationFocusRequester = remember { FocusRequester() }, + onRetry = {}, + ) + } +} + /** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */ @Composable private fun MaintenanceBackdrop(glow: Float) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt index 80a140a..ac22c27 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -60,7 +60,7 @@ import kotlinx.coroutines.launch private val UpdateAccent = Color(0xFF52B54B) private val UpdateTitle = Color(0xFFF2F5F7) private val UpdateBody = Color(0xFFAEB7BF) -private val UpdateFaint = Color(0xFF7E888F) +private val UpdateFaint = Color(0xFFA2ADB5) /** * The update prompt, shown over the home screen. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 90634c2..cd92741 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -1,44 +1,72 @@ package com.ponzischeme89.memby.ui.player +import android.annotation.SuppressLint import android.app.AlertDialog import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.content.Context import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface import android.os.Bundle import android.os.SystemClock import android.text.format.DateFormat import android.util.Log +import android.view.Gravity import android.view.KeyEvent import android.view.View +import android.view.ViewGroup import android.view.WindowManager +import android.view.animation.DecelerateInterpolator import android.view.animation.LinearInterpolator import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import androidx.activity.ComponentActivity +import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import androidx.media3.ui.SubtitleView import androidx.lifecycle.lifecycleScope import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle +import androidx.core.view.isVisible +import coil.imageLoader import coil.load +import coil.request.ImageRequest import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.NextEpisode +import com.ponzischeme89.memby.data.PlayableSubtitle +import com.ponzischeme89.memby.data.PlaybackSession +import com.ponzischeme89.memby.data.model.EmbyPerson +import com.ponzischeme89.memby.data.model.GatewayPrerollEntry +import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.ui.MainActivity import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.util.Date +import java.util.Locale import kotlin.math.ceil /** @@ -54,12 +82,55 @@ class PlayerActivity : ComponentActivity() { private var playbackStarted = false private var stopReported = false private var itemId: String? = null + private var mediaSourceId = "" + private var playSessionId = "" + private var playMethod = "DirectPlay" + private var stoppedInBackground = false + private var availableSubtitles: List = emptyList() + private var encodedSubtitleId: String? = null private var remainingView: TextView? = null private var finishTimeView: TextView? = null private var loadingView: View? = null + private var loadingTitleView: TextView? = null + private var loadingHintView: TextView? = null private var loadingAnimator: ObjectAnimator? = null + private var errorView: View? = null + private var errorTitleView: TextView? = null + private var errorDetailView: TextView? = null + private var streamStatusView: TextView? = null + private var retryJob: Job? = null + private var stablePlaybackJob: Job? = null + private var automaticRetryAttempt = 0 private var renderedFirstFrame = false private var requestStartedAtMs = 0L + private var logoUrl: String? = null + private var playbackTitle = "" + private var bufferingStartedAtMs: Long? = null + private var totalBufferingMs = 0L + private var bufferingCount = 0 + private var subtitleAutoSelectionAttempted = false + private var subtitlePreference: Boolean? = null + private var currentVideoSize = VideoSize.UNKNOWN + private var subtitleOverlay: View? = null + private var castOverlay: View? = null + private var castJob: Job? = null + private var castPeople: List = emptyList() + private var castLoaded = false + private var prerollView: View? = null + private var prerollTimerJob: Job? = null + private var prerollScheduleJob: Job? = null + private var prerollActive = true + private var prerollHandOffStarted = false + private var prerollMinimumElapsed = false + + // "Next up" state. [nextEpisode] is prefetched as soon as playback settles so the + // banner can appear — and the next episode start — without waiting on the network. + private var nextEpisode: NextEpisode? = null + private var nextUpJob: Job? = null + private var nextUpBanner: View? = null + private var nextUpCountdown: TextView? = null + private var nextUpDismissed = false + private var advancing = false @UnstableApi override fun onCreate(savedInstanceState: Bundle?) { @@ -72,7 +143,13 @@ class PlayerActivity : ComponentActivity() { SystemClock.elapsedRealtime(), ) itemId = intent.getStringExtra(EXTRA_ITEM_ID) + mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty() + playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty() + playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD) ?: "DirectPlay" val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) + val subtitles = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES)) + availableSubtitles = subtitles + Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}") if (url.isNullOrBlank()) { finish() return @@ -80,12 +157,24 @@ class PlayerActivity : ComponentActivity() { setContentView(R.layout.activity_player) val view = findViewById(R.id.player_view).apply { - useController = true + useController = false setShowSubtitleButton(true) controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS } + applySubtitleAppearance(view) playerView = view + view.findViewById(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener { + showSubtitleOverlay() + } + view.findViewById(R.id.player_hide_controls)?.setOnClickListener { view.hideController() } + view.findViewById(R.id.player_exit)?.setOnClickListener { finish() } + view.findViewById(androidx.media3.ui.R.id.exo_settings)?.setOnClickListener { + showTrackMenu() + } + applyPictureMode() loadingView = findViewById(R.id.playback_loading) + loadingTitleView = findViewById(R.id.playback_loading_title) + loadingHintView = findViewById(R.id.playback_loading_hint) loadingAnimator = ObjectAnimator.ofFloat( findViewById(R.id.playback_loading_logo), View.ROTATION, @@ -97,14 +186,27 @@ class PlayerActivity : ComponentActivity() { interpolator = LinearInterpolator() start() } - remainingView = findViewById(R.id.player_remaining) - finishTimeView = findViewById(R.id.player_finish_time) - bindTitleArtwork( - title = intent.getStringExtra(EXTRA_TITLE).orEmpty(), - logoUrl = intent.getStringExtra(EXTRA_LOGO_URL), - ) + // Both live in memby_player_controls.xml, which PlayerView inflates for itself via + // app:controller_layout_id — so they hang off the PlayerView, not the activity's layout. + remainingView = view.findViewById(R.id.player_remaining) + finishTimeView = view.findViewById(R.id.player_finish_time) + streamStatusView = view.findViewById(R.id.player_stream_status) + logoUrl = intent.getStringExtra(EXTRA_LOGO_URL) + playbackTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty() + bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) + setUpSubtitleOverlay() + setUpCastOverlay() + setUpNextUpBanner() + setUpPlaybackError() + startPreroll() + loadCast() val selector = DefaultTrackSelector(this) + val renderersFactory = DefaultRenderersFactory(this) + // Some Android TV firmwares advertise a preferred hardware decoder which + // fails only after initialization. Let Media3 try another installed decoder + // before declaring the file unsupported. + .setEnableDecoderFallback(true) val loadControl = DefaultLoadControl.Builder() .setBufferDurationsMs( MIN_BUFFER_MS, @@ -115,6 +217,7 @@ class PlayerActivity : ComponentActivity() { .setPrioritizeTimeOverSizeThresholds(true) .build() player = ExoPlayer.Builder(this) + .setRenderersFactory(renderersFactory) .setTrackSelector(selector) .setLoadControl(loadControl) .build() @@ -122,23 +225,52 @@ class PlayerActivity : ComponentActivity() { view.player = playback playback.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { + recordPlaybackState(playbackState) when (playbackState) { - Player.STATE_BUFFERING -> showPlaybackLoading() - Player.STATE_READY -> if (renderedFirstFrame) hidePlaybackLoading() - } - if (playbackState == Player.STATE_READY && !playbackStarted) { - playbackStarted = true - reportStarted(playback.currentPosition) - startProgressReporting() + Player.STATE_BUFFERING -> if (!prerollActive) showPlaybackLoading() + Player.STATE_READY -> { + hidePlaybackError() + if (!prerollActive && renderedFirstFrame) { + hidePlaybackLoading() + } + } + Player.STATE_ENDED -> nextEpisode + ?.takeIf { !nextUpDismissed } + ?.let(::playNext) + Player.STATE_IDLE -> Unit } } override fun onIsPlayingChanged(isPlaying: Boolean) { - if (playbackStarted) reportProgress(playback.currentPosition, isPaused = !isPlaying) + if (playbackStarted) { + reportProgress( + playback.currentPosition, + isPaused = !isPlaying, + eventName = if (isPlaying) "Unpause" else "Pause", + ) + } + if (isPlaying) scheduleRetryBudgetReset() else stablePlaybackJob?.cancel() } override fun onEvents(player: Player, events: Player.Events) { updatePlaybackTiming(player) + if (playbackStarted && events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { + reportProgress(player.currentPosition, !player.isPlaying, "TimeUpdate") + } + } + + override fun onTracksChanged(tracks: Tracks) { + updateStreamStatus(tracks) + ensureSubtitleSelected(tracks) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + currentVideoSize = videoSize + applyPictureMode() + } + + override fun onPlayerError(error: PlaybackException) { + handlePlaybackError(error) } override fun onRenderedFirstFrame() { @@ -151,8 +283,10 @@ class PlayerActivity : ComponentActivity() { ) } }) - playback.setMediaItem(MediaItem.fromUri(url), resumePositionMs) - playback.playWhenReady = true + playback.setMediaItem(mediaItem(url, subtitles), resumePositionMs) + // Buffer the real programme while the cheap pre-roll overlay is visible. + // No second player, decoder or media request is involved. + playback.playWhenReady = false playback.prepare() } @@ -165,7 +299,141 @@ class PlayerActivity : ComponentActivity() { } } - private fun showPlaybackLoading() { + private fun startPreroll() { + val overlay = findViewById(R.id.player_preroll) + prerollView = overlay + prerollActive = true + prerollHandOffStarted = false + prerollMinimumElapsed = false + overlay.visibility = View.VISIBLE + loadingView?.visibility = View.GONE + playerView?.apply { + useController = false + hideController() + } + bindPrerollSchedule(GatewayPrerollSchedule(), loading = true) + prerollScheduleJob = lifecycleScope.launch { + val schedule = ServiceLocator.repository.prerollSchedule() + if (prerollActive) bindPrerollSchedule(schedule, loading = false) + } + prerollTimerJob = lifecycleScope.launch { + val countdown = findViewById(R.id.player_preroll_countdown) + for (seconds in PREROLL_SECONDS downTo 1) { + countdown.text = resources.getQuantityString( + R.plurals.player_preroll_countdown, + seconds, + seconds, + ) + delay(1_000L) + } + prerollMinimumElapsed = true + countdown.text = getString(R.string.player_preroll_starting) + beginContentWhenReady() + } + } + + private fun beginContentWhenReady() { + if (prerollHandOffStarted) return + if (!prerollCanHandOff( + active = prerollActive, + minimumElapsed = prerollMinimumElapsed, + lifecycleStarted = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED), + ) + ) return + val playback = player ?: return + prerollHandOffStarted = true + prerollActive = false + prerollView?.visibility = View.GONE + playerView?.apply { + useController = true + hideController() + } + startPlaybackSession(playback) + playback.playWhenReady = true + if (playback.playbackState != Player.STATE_READY) { + showPlaybackLoading() + } + } + + private fun startPlaybackSession(playback: Player) { + if (playbackStarted) return + playbackStarted = true + reportStarted(playback.currentPosition) + startProgressReporting() + prefetchNextEpisode() + startNextUpWatch() + } + + private fun bindPrerollSchedule(schedule: GatewayPrerollSchedule, loading: Boolean) { + val today = findViewById(R.id.player_preroll_today) + val week = findViewById(R.id.player_preroll_week) + today.removeAllViews() + week.removeAllViews() + + if (schedule.today.isEmpty() && schedule.thisWeek.isEmpty()) { + today.addView( + prerollMessage( + if (loading) "Checking what’s coming up…" + else "Nothing else is scheduled today or this week.", + ), + ) + } else { + schedule.today.forEach { today.addView(prerollEntry(it)) } + schedule.thisWeek.forEach { week.addView(prerollEntry(it)) } + } + findViewById(R.id.player_preroll_today_label).visibility = + if (schedule.today.isNotEmpty() || schedule.thisWeek.isEmpty()) View.VISIBLE else View.GONE + findViewById(R.id.player_preroll_week_label).visibility = + if (schedule.thisWeek.isNotEmpty()) View.VISIBLE else View.GONE + } + + private fun prerollEntry(entry: GatewayPrerollEntry): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { bottomMargin = dp(11) } + addView( + TextView(this@PlayerActivity).apply { + text = entry.series + setTextColor(Color.WHITE) + textSize = 16f + typeface = Typeface.create("sans-serif", Typeface.BOLD) + maxLines = 1 + }, + ) + val detail = listOfNotNull( + entry.schedule.takeIf(String::isNotBlank), + entry.episodeCode.takeIf(String::isNotBlank), + entry.episode.takeIf(String::isNotBlank), + entry.availability.takeIf(String::isNotBlank), + ).joinToString(" · ") + addView( + TextView(this@PlayerActivity).apply { + text = detail + setTextColor(Color.rgb(142, 151, 157)) + textSize = 12f + maxLines = 1 + }, + ) + } + + private fun prerollMessage(message: String): TextView = + TextView(this).apply { + text = message + setTextColor(Color.rgb(142, 151, 157)) + textSize = 14f + setPadding(0, dp(7), 0, dp(7)) + } + + private fun showPlaybackLoading( + title: String = getString(R.string.playback_loading), + hint: String = getString(R.string.playback_loading_hint), + ) { + if (prerollActive) return + loadingTitleView?.text = title + loadingHintView?.text = hint loadingView?.visibility = View.VISIBLE if (loadingAnimator?.isStarted != true) loadingAnimator?.start() } @@ -175,6 +443,237 @@ class PlayerActivity : ComponentActivity() { loadingAnimator?.cancel() } + private fun setUpPlaybackError() { + errorView = findViewById(R.id.playback_error).also { overlay -> + errorTitleView = overlay.findViewById(R.id.playback_error_title) + errorDetailView = overlay.findViewById(R.id.playback_error_detail) + overlay.findViewById(R.id.playback_error_retry).setOnClickListener { + automaticRetryAttempt = 0 + retryPlayback(refreshSource = true) + } + overlay.findViewById(R.id.playback_error_exit).setOnClickListener { + finish() + } + } + } + + private fun handlePlaybackError(error: PlaybackException) { + if (prerollActive) { + prerollActive = false + prerollView?.visibility = View.GONE + playerView?.useController = true + } + retryJob?.cancel() + stablePlaybackJob?.cancel() + val failure = describePlaybackFailure(error.errorCode) + val positionMs = player?.currentPosition ?: 0L + Log.e( + PLAYBACK_LOG_TAG, + "event=error item=${itemId.orEmpty()} code=${error.errorCode} " + + "name=${error.errorCodeName} positionMs=$positionMs " + + "bufferedMs=${player?.bufferedPosition ?: 0L} attempt=$automaticRetryAttempt", + error, + ) + + val nextAttempt = automaticRetryAttempt + 1 + val delayMs = automaticRetryDelayMs(nextAttempt) + if (failure.canAutoRetry && delayMs != null) { + automaticRetryAttempt = nextAttempt + val seconds = (delayMs / 1_000L).coerceAtLeast(1L) + showPlaybackLoading( + title = getString(R.string.playback_reconnecting), + hint = resources.getQuantityString( + R.plurals.playback_retrying_in, + seconds.toInt(), + seconds, + ), + ) + retryJob = lifecycleScope.launch { + delay(delayMs) + retryPlayback(refreshSource = nextAttempt >= FRESH_STREAM_RETRY_ATTEMPT) + } + return + } + + showPlaybackError(failure) + } + + private fun retryPlayback(refreshSource: Boolean) { + retryJob?.cancel() + hidePlaybackError() + val playback = player ?: return + val positionMs = playback.currentPosition.coerceAtLeast(0L) + showPlaybackLoading( + title = getString(R.string.playback_reconnecting), + hint = getString( + if (refreshSource) R.string.playback_refreshing_stream else R.string.playback_retrying_now, + ), + ) + Log.i( + PLAYBACK_LOG_TAG, + "event=retry item=${itemId.orEmpty()} attempt=$automaticRetryAttempt " + + "freshStream=$refreshSource positionMs=$positionMs", + ) + + val id = itemId + if (!refreshSource || id.isNullOrBlank()) { + playback.seekTo(positionMs) + playback.playWhenReady = true + playback.prepare() + return + } + + retryJob = lifecycleScope.launch { + runCatching { + ServiceLocator.repository.refreshPlayableStream( + itemId = id, + title = playbackTitle, + resumePositionMs = positionMs, + ) + }.onSuccess { refreshed -> + if (isFinishing || isDestroyed) return@onSuccess + itemId = refreshed.itemId + mediaSourceId = refreshed.mediaSourceId + playSessionId = refreshed.playSessionId + playMethod = refreshed.playMethod + availableSubtitles = refreshed.subtitles + if (refreshed.title.isNotBlank()) playbackTitle = refreshed.title + subtitleAutoSelectionAttempted = false + Log.i( + PLAYBACK_LOG_TAG, + "event=subtitle_configs item=${refreshed.itemId} count=${refreshed.subtitles.size} source=refresh", + ) + playback.setMediaItem(mediaItem(refreshed.url, refreshed.subtitles), positionMs) + playback.playWhenReady = true + playback.prepare() + }.onFailure { refreshError -> + Log.e( + PLAYBACK_LOG_TAG, + "event=stream_refresh_failed item=$id attempt=$automaticRetryAttempt", + refreshError, + ) + showPlaybackError( + PlaybackFailure( + title = getString(R.string.playback_server_unreachable), + detail = getString(R.string.playback_server_unreachable_detail), + canAutoRetry = false, + ), + ) + } + } + } + + private fun showPlaybackError(failure: PlaybackFailure) { + hidePlaybackLoading() + playerView?.hideController() + errorTitleView?.text = failure.title + errorDetailView?.text = failure.detail + errorView?.apply { + visibility = View.VISIBLE + findViewById(R.id.playback_error_retry).requestFocus() + } + } + + private fun hidePlaybackError() { + errorView?.visibility = View.GONE + } + + private fun scheduleRetryBudgetReset() { + stablePlaybackJob?.cancel() + stablePlaybackJob = lifecycleScope.launch { + delay(STABLE_PLAYBACK_RESET_MS) + automaticRetryAttempt = 0 + Log.i(PLAYBACK_LOG_TAG, "event=recovery_budget_reset item=${itemId.orEmpty()}") + } + } + + private fun recordPlaybackState(state: Int) { + val now = SystemClock.elapsedRealtime() + if (state == Player.STATE_BUFFERING && bufferingStartedAtMs == null) { + bufferingStartedAtMs = now + bufferingCount += 1 + } else if (state != Player.STATE_BUFFERING) { + bufferingStartedAtMs?.let { totalBufferingMs += now - it } + bufferingStartedAtMs = null + } + Log.d( + PLAYBACK_LOG_TAG, + "event=state item=${itemId.orEmpty()} state=${playbackStateName(state)} " + + "positionMs=${player?.currentPosition ?: 0L}", + ) + } + + private fun updateStreamStatus(tracks: Tracks) { + val selected = tracks.groups.flatMap { group -> + (0 until group.length) + .filter(group::isTrackSelected) + .map(group::getTrackFormat) + } + val video = selected.firstOrNull { it.sampleMimeType?.startsWith("video/") == true } + val audio = selected.firstOrNull { it.sampleMimeType?.startsWith("audio/") == true } + val resolution = when { + video == null || video.height <= 0 -> null + video.height >= 2160 -> "4K" + else -> "${video.height}p" + } + val codec = video?.sampleMimeType + ?.substringAfter('/') + ?.replace("x-", "") + ?.uppercase() + val channels = audio?.channelCount?.takeIf { it > 0 }?.let { "$it CH" } + val label = listOfNotNull(resolution, codec, channels).joinToString(" • ") + streamStatusView?.apply { + text = label + visibility = if (label.isBlank()) View.GONE else View.VISIBLE + } + if (label.isNotBlank()) { + Log.i(PLAYBACK_LOG_TAG, "event=tracks item=${itemId.orEmpty()} selected=\"$label\"") + } + } + + @OptIn(UnstableApi::class) + private fun ensureSubtitleSelected(tracks: Tracks) { + val textTracks = buildList { + tracks.groups.forEach { group -> + if (group.type != C.TRACK_TYPE_TEXT) return@forEach + for (index in 0 until group.mediaTrackGroup.length) { + if (group.isTrackSupported(index)) add(TrackChoice(group.mediaTrackGroup, index, "")) + } + } + } + val selectedCount = tracks.groups.sumOf { group -> + if (group.type != C.TRACK_TYPE_TEXT) 0 + else (0 until group.mediaTrackGroup.length).count(group::isTrackSelected) + } + Log.i( + PLAYBACK_LOG_TAG, + "event=subtitle_tracks item=${itemId.orEmpty()} available=${textTracks.size} selected=$selectedCount", + ) + if (subtitlePreference == false || subtitleAutoSelectionAttempted || textTracks.isEmpty()) return + subtitleAutoSelectionAttempted = true + if (selectedCount > 0) return + + val choice = textTracks.minByOrNull { candidate -> + val flags = candidate.group?.getFormat(candidate.index)?.selectionFlags ?: 0 + when { + flags and C.SELECTION_FLAG_FORCED != 0 -> 0 + flags and C.SELECTION_FLAG_DEFAULT != 0 -> 1 + else -> 2 + } + } ?: return + val group = choice.group ?: return + val playback = player ?: return + playback.trackSelectionParameters = playback.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setOverrideForType(TrackSelectionOverride(group, listOf(choice.index))) + .build() + Log.i( + PLAYBACK_LOG_TAG, + "event=subtitle_auto_selected item=${itemId.orEmpty()} track=${choice.index}", + ) + } + private fun bindTitleArtwork(title: String, logoUrl: String?) { val logo = findViewById(R.id.player_title_logo) val fallback = findViewById(R.id.player_title).apply { @@ -203,7 +702,9 @@ class PlayerActivity : ComponentActivity() { private fun updatePlaybackTiming(playback: Player) { val duration = playback.duration if (duration == C.TIME_UNSET || duration <= 0L) { - remainingView?.text = if (playback.isCurrentMediaItemLive) "Live" else "Loading duration…" + remainingView?.setText( + if (playback.isCurrentMediaItemLive) R.string.player_live else R.string.player_loading_duration, + ) finishTimeView?.text = "" return } @@ -213,7 +714,226 @@ class PlayerActivity : ComponentActivity() { val wallClockRemainingMs = (remainingMs / speed).toLong() remainingView?.text = formatRemaining(wallClockRemainingMs) val finishAt = System.currentTimeMillis() + wallClockRemainingMs - finishTimeView?.text = "Ends at ${DateFormat.getTimeFormat(this).format(Date(finishAt))}" + finishTimeView?.text = getString( + R.string.player_ends_at, + DateFormat.getTimeFormat(this).format(Date(finishAt)), + ) + } + + // --- Next up ------------------------------------------------------------------ + + private fun setUpNextUpBanner() { + val banner = findViewById(R.id.player_next_up) + nextUpBanner = banner + nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown) + banner.findViewById(R.id.player_next_up_play).setOnClickListener { + nextEpisode?.let(::playNext) + } + banner.findViewById(R.id.player_next_up_dismiss).setOnClickListener { + dismissNextUp() + } + } + + /** + * Resolves what follows the current item in the background. A movie has no next + * episode and the lookup simply comes back null, so there is nothing to special-case + * here; the banner only ever appears when there is something real to show. + */ + private fun prefetchNextEpisode() { + nextEpisode = null + val id = itemId?.takeIf { it.isNotBlank() } ?: return + lifecycleScope.launch { + val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode + if (!enabled) return@launch + val resolved = ServiceLocator.repository.nextEpisode(id, seriesId = null) + resolved?.imageUrl?.let { imageUrl -> + imageLoader.enqueue( + ImageRequest.Builder(this@PlayerActivity) + .data(imageUrl) + .size(NEXT_UP_IMAGE_PREFETCH_WIDTH, NEXT_UP_IMAGE_PREFETCH_HEIGHT) + .build(), + ) + } + // Playback may have moved on to another episode while this was in flight. + if (itemId == id) { + nextEpisode = resolved + // Extremely short episodes can end before the background lookup returns. + if (player?.playbackState == Player.STATE_ENDED && !nextUpDismissed) { + resolved?.let(::playNext) + } + } + } + } + + /** + * Watches the media playhead rather than wall-clock time. This makes the transition + * deterministic: pause freezes the countdown and seeking out of the final minute + * restores fullscreen playback. + */ + private fun startNextUpWatch() { + nextUpJob?.cancel() + nextUpJob = lifecycleScope.launch { + while (isActive) { + delay(NEXT_UP_TICK_MS) + updateNextUpFromPlayhead() + } + } + } + + private fun updateNextUpFromPlayhead() { + if (advancing) return + val playback = player ?: return + val next = nextEpisode ?: return + val duration = playback.duration + if (duration == C.TIME_UNSET || duration <= 0L) return + + val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) + when { + remainingMs > NEXT_UP_LEAD_MS -> { + hideNextUp() + nextUpDismissed = false + } + nextUpDismissed -> Unit + remainingMs == 0L -> playNext(next) + else -> { + showNextUp(next) + val seconds = ceil(remainingMs / 1_000.0).toInt() + nextUpCountdown?.text = getString(R.string.next_up_starting_in, seconds) + } + } + } + + @OptIn(UnstableApi::class) + private fun showNextUp(next: NextEpisode) { + val banner = nextUpBanner ?: return + if (banner.isVisible) return + + banner.findViewById(R.id.player_next_up_title).text = + next.title.ifBlank { next.seriesName } + val meta = listOfNotNull( + next.episodeCode, + next.seriesName.takeIf { it.isNotBlank() && next.title.isNotBlank() }, + ).joinToString(" · ") + banner.findViewById(R.id.player_next_up_meta).apply { + text = meta + visibility = if (meta.isBlank()) View.GONE else View.VISIBLE + } + banner.findViewById(R.id.player_next_up_image).load(next.imageUrl) { + crossfade(true) + } + + banner.alpha = 0f + banner.visibility = View.VISIBLE + banner.post { + zoomVideoForNextUp() + banner.animate() + .alpha(1f) + .setDuration(NEXT_UP_ANIMATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + playerView?.hideController() + banner.findViewById(R.id.player_next_up_play).requestFocus() + } + } + + private fun hideNextUp() { + val banner = nextUpBanner ?: return + if (!banner.isVisible) return + banner.animate() + .alpha(0f) + .setDuration(NEXT_UP_ANIMATION_MS) + .withEndAction { + banner.visibility = View.GONE + banner.alpha = 1f + } + .start() + restoreVideoAfterNextUp() + } + + private fun dismissNextUp() { + nextUpDismissed = true + nextUpJob?.cancel() + hideNextUp() + playerView?.requestFocus() + } + + private fun zoomVideoForNextUp() { + val view = playerView ?: return + view.animate() + .scaleX(NEXT_UP_VIDEO_SCALE) + .scaleY(NEXT_UP_VIDEO_SCALE) + .translationX(-view.width * NEXT_UP_VIDEO_SHIFT_X) + .translationY(-view.height * NEXT_UP_VIDEO_SHIFT_Y) + .setDuration(NEXT_UP_ANIMATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + } + + private fun restoreVideoAfterNextUp() { + playerView?.animate() + ?.scaleX(1f) + ?.scaleY(1f) + ?.translationX(0f) + ?.translationY(0f) + ?.setDuration(NEXT_UP_ANIMATION_MS) + ?.setInterpolator(DecelerateInterpolator()) + ?.start() + } + + /** + * Rolls into [next] inside the running player rather than relaunching the activity: + * no teardown, no black frame between episodes. The outgoing episode is reported + * stopped first so Emby records it as finished. + */ + private fun playNext(next: NextEpisode) { + if (advancing) return + advancing = true + nextUpJob?.cancel() + progressJob?.cancel() + hideNextUp() + + val playback = player + val previousId = itemId + if (!previousId.isNullOrBlank() && playbackStarted && !stopReported) { + stopReported = true + PlaybackStopWorker.enqueue( + this, + playbackSession(previousId), + playback?.currentPosition ?: 0L, + ) + } + + itemId = next.itemId + mediaSourceId = next.mediaSourceId + playSessionId = next.playSessionId + playMethod = next.playMethod + availableSubtitles = next.subtitles + stopReported = false + playbackStarted = false + renderedFirstFrame = false + automaticRetryAttempt = 0 + nextEpisode = null + nextUpDismissed = false + requestStartedAtMs = SystemClock.elapsedRealtime() + subtitleAutoSelectionAttempted = false + loadCast() + + playbackTitle = nextTitle(next) + bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) + hidePlaybackError() + showPlaybackLoading() + playback?.apply { + setMediaItem(mediaItem(next.url, next.subtitles), next.resumePositionMs) + playWhenReady = true + prepare() + } + advancing = false + } + + private fun nextTitle(next: NextEpisode): String = buildString { + next.seriesName.takeIf { it.isNotBlank() }?.let(::append) + if (isNotEmpty() && next.title.isNotBlank()) append(" – ") + append(next.title) } private fun interruptForMaintenance() { @@ -227,7 +947,28 @@ class PlayerActivity : ComponentActivity() { finish() } + // Activity.dispatchKeyEvent is public platform API; the @RestrictTo lives on the + // androidx.core.app.ComponentActivity override we inherit, so overriding it is safe. + @SuppressLint("RestrictedApi") override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + // Consume DOWN as well as UP. Letting the platform see DOWN can finish the + // activity before our UP handler gets a chance to hide the active surface. + if (event.action == KeyEvent.ACTION_UP) { + when { + castOverlay?.isVisible == true -> hideCastOverlay() + subtitleOverlay?.isVisible == true -> hideSubtitleOverlay() + nextUpBanner?.isVisible == true -> dismissNextUp() + playerView?.isControllerFullyVisible == true -> playerView?.hideController() + else -> finish() + } + } + return true + } + if (prerollActive && event.keyCode in PREROLL_BLOCKED_KEYS) { + // The pre-roll is intentionally non-interactive and has no transport OSD. + return true + } if (event.action == KeyEvent.ACTION_UP && event.keyCode in setOf(KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS)) { showTrackMenu() return true @@ -235,32 +976,394 @@ class PlayerActivity : ComponentActivity() { return super.dispatchKeyEvent(event) } + @OptIn(UnstableApi::class) private fun showTrackMenu() { val playback = player ?: return val audio = playback.currentTracks.groups.any { group -> group.type == C.TRACK_TYPE_AUDIO && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported) } - val subtitles = playback.currentTracks.groups.any { group -> - group.type == C.TRACK_TYPE_TEXT && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported) - } val options = buildList { if (audio) add("Audio") - if (subtitles) add("Subtitles") - } - if (options.isEmpty()) { - AlertDialog.Builder(this).setMessage("No alternate audio or subtitle tracks are available.") - .setPositiveButton("OK", null).show() - return + add("Subtitles & appearance") + add("Cast") + add("Picture size · ${selectedPictureMode().shortLabel}") } AlertDialog.Builder(this) .setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options") .setItems(options.toTypedArray()) { _, which -> - showTrackPicker(if (options[which] == "Audio") C.TRACK_TYPE_AUDIO else C.TRACK_TYPE_TEXT) + when (options[which]) { + "Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO) + "Subtitles & appearance" -> showSubtitleOverlay() + "Cast" -> showCastOverlay() + else -> showPictureModePicker() + } } .show() } + private fun showPictureModePicker() { + val selected = selectedPictureMode() + AlertDialog.Builder(this) + .setTitle("Picture size") + .setSingleChoiceItems( + PictureMode.entries.map(PictureMode::label).toTypedArray(), + PictureMode.entries.indexOf(selected), + ) { dialog, which -> + val mode = PictureMode.entries[which] + getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(PICTURE_MODE_KEY, mode.key) + .apply() + applyPictureMode(mode) + dialog.dismiss() + playerView?.hideController() + } + .show() + } + + private fun selectedPictureMode(): PictureMode { + val key = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE) + .getString(PICTURE_MODE_KEY, PictureMode.AUTO.key) + return PictureMode.entries.firstOrNull { it.key == key } ?: PictureMode.AUTO + } + + private fun applyPictureMode(mode: PictureMode = selectedPictureMode()) { + val zoom = shouldZoomVideo( + modeKey = mode.key, + width = currentVideoSize.width, + height = currentVideoSize.height, + pixelWidthHeightRatio = currentVideoSize.pixelWidthHeightRatio, + ) + playerView?.resizeMode = if (zoom) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIT + } + } + + @OptIn(UnstableApi::class) + private fun applySubtitleAppearance(view: PlayerView? = playerView) { + val preference = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE) + .getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key) + val size = SubtitleSize.entries.firstOrNull { it.key == preference } ?: SubtitleSize.MEDIUM + view?.subtitleView?.apply { + setStyle( + CaptionStyleCompat( + Color.WHITE, + Color.TRANSPARENT, + Color.TRANSPARENT, + CaptionStyleCompat.EDGE_TYPE_OUTLINE, + Color.BLACK, + null, + ), + ) + setFractionalTextSize(size.fraction) + // Media3 defaults to 8% of view height. An extra 1.5% is roughly 16px on + // a 1080p TV and keeps captions clear of the physical screen edge. + setBottomPaddingFraction(SUBTITLE_BOTTOM_PADDING_FRACTION) + } + } + + private fun setUpSubtitleOverlay() { + subtitleOverlay = findViewById(R.id.player_subtitle_overlay).apply { + setOnClickListener { hideSubtitleOverlay() } + } + } + + private fun setUpCastOverlay() { + castOverlay = findViewById(R.id.player_cast_overlay).apply { + setOnClickListener { hideCastOverlay() } + } + } + + private fun loadCast() { + castJob?.cancel() + castLoaded = false + castPeople = emptyList() + val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run { + castLoaded = true + bindCastOverlay() + return + } + castJob = lifecycleScope.launch { + val loaded = runCatching { + ServiceLocator.repository.getItemDetails(requestedItemId).cast.take(MAX_CAST_MEMBERS) + }.getOrDefault(emptyList()) + if (itemId == requestedItemId) { + castPeople = loaded + castLoaded = true + bindCastOverlay() + } + } + } + + private fun showCastOverlay() { + playerView?.hideController() + castOverlay?.visibility = View.VISIBLE + bindCastOverlay() + if (!castLoaded && castJob?.isActive != true) loadCast() + } + + private fun hideCastOverlay() { + castOverlay?.visibility = View.GONE + playerView?.showController() + } + + private fun bindCastOverlay() { + val overlay = castOverlay ?: return + val status = overlay.findViewById(R.id.player_cast_status) + val people = overlay.findViewById(R.id.player_cast_people) + people.removeAllViews() + status.apply { + text = when { + !castLoaded -> getString(R.string.player_cast_loading) + castPeople.isEmpty() -> getString(R.string.player_cast_empty) + else -> "" + } + visibility = if (text.isNullOrEmpty()) View.GONE else View.VISIBLE + } + castPeople.forEach { person -> people.addView(playerCastCard(person)) } + if (overlay.isVisible) { + people.getChildAt(0)?.requestFocus() ?: overlay.requestFocus() + } + } + + private fun playerCastCard(person: EmbyPerson): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + isFocusable = true + isClickable = true + setOnClickListener { } + layoutParams = LinearLayout.LayoutParams(dp(120), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + marginEnd = dp(16) + } + setOnFocusChangeListener { view, focused -> + view.animate() + .scaleX(if (focused) 1.045f else 1f) + .scaleY(if (focused) 1.045f else 1f) + .setDuration(110L) + .start() + } + addView( + ImageView(this@PlayerActivity).apply { + layoutParams = LinearLayout.LayoutParams(dp(120), dp(158)) + background = getDrawable(R.drawable.player_cast_portrait_background) + clipToOutline = true + scaleType = ImageView.ScaleType.CENTER_CROP + contentDescription = person.name + ServiceLocator.repository.personImageUrl(person, 280)?.let { load(it) } + }, + ) + addView( + TextView(this@PlayerActivity).apply { + text = person.name + setTextColor(Color.WHITE) + textSize = 14f + typeface = Typeface.create("sans-serif", Typeface.BOLD) + maxLines = 1 + setPadding(0, dp(7), 0, 0) + }, + ) + person.role?.takeIf(String::isNotBlank)?.let { role -> + addView( + TextView(this@PlayerActivity).apply { + text = role + setTextColor(Color.rgb(174, 183, 191)) + textSize = 11f + maxLines = 1 + }, + ) + } + } + + @OptIn(UnstableApi::class) + private fun showSubtitleOverlay( + focusTrack: Int? = null, + focusSize: Int? = null, + ) { + val playback = player ?: return + val overlay = subtitleOverlay ?: return + val tracksContainer = overlay.findViewById(R.id.player_subtitle_tracks) + val sizesContainer = overlay.findViewById(R.id.player_subtitle_sizes) + tracksContainer.removeAllViews() + sizesContainer.removeAllViews() + + val discoveredTracks = mutableListOf() + playback.currentTracks.groups.forEach { group -> + if (group.type != C.TRACK_TYPE_TEXT) return@forEach + for (index in 0 until group.mediaTrackGroup.length) { + if (group.isTrackSupported(index)) { + discoveredTracks += TrackChoice( + group.mediaTrackGroup, + index, + trackLabel(group.mediaTrackGroup, index), + ) + } + } + } + fun isSelected(choice: TrackChoice): Boolean = + choice.group != null && playback.currentTracks.groups.any { current -> + current.mediaTrackGroup == choice.group && current.isTrackSelected(choice.index) + } + // Media3 may expose an embedded stream and the normalized Emby sidecar as + // equivalent tracks. Show one meaningful choice, preferring the selected copy. + val tracks = mutableListOf(TrackChoice(null, -1, "Off")).apply { + addAll( + discoveredTracks.groupBy { it.label.lowercase() }.values.map { equivalents -> + equivalents.firstOrNull(::isSelected) ?: equivalents.first() + }, + ) + addAll( + availableSubtitles + .filter { it.deliveryMethod.equals("Encode", true) } + .map { subtitle -> + TrackChoice( + group = null, + index = subtitle.id.toIntOrNull() ?: -1, + label = subtitleDisplayLabel(subtitle) + " · Burned in", + encodedSubtitle = subtitle, + ) + }, + ) + } + val selectedTrack = tracks.indexOfFirst { choice -> + isSelected(choice) || choice.encodedSubtitle?.id == encodedSubtitleId + }.let { if (it >= 0) it else 0 } + + tracks.forEachIndexed { index, choice -> + tracksContainer.addView( + subtitleOption(choice.label, selected = index == selectedTrack) { + choice.encodedSubtitle?.let { subtitle -> + selectEncodedSubtitle(subtitle) + return@subtitleOption + } + val builder = playback.trackSelectionParameters.buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, choice.group == null) + choice.group?.let { + builder.setOverrideForType(TrackSelectionOverride(it, listOf(choice.index))) + } + playback.trackSelectionParameters = builder.build() + subtitlePreference = choice.group != null + encodedSubtitleId = null + subtitleAutoSelectionAttempted = true + reportProgress(playback.currentPosition, !playback.isPlaying, "SubtitleTrackChange") + showSubtitleOverlay(focusTrack = index) + }, + ) + } + + val preferences = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE) + val currentSize = preferences.getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key) + SubtitleSize.entries.forEachIndexed { index, size -> + sizesContainer.addView( + subtitleOption(size.label, selected = size.key == currentSize) { + preferences.edit().putString(SUBTITLE_SIZE_KEY, size.key).apply() + applySubtitleAppearance() + showSubtitleOverlay(focusSize = index) + }, + ) + } + + playerView?.hideController() + overlay.visibility = View.VISIBLE + val target = when { + focusTrack != null -> tracksContainer.getChildAt(focusTrack) + focusSize != null -> sizesContainer.getChildAt(focusSize) + else -> tracksContainer.getChildAt(selectedTrack) + } + target?.requestFocus() + } + + private fun subtitleOption( + label: String, + selected: Boolean, + onClick: () -> Unit, + ): TextView = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(52), + ).apply { + bottomMargin = dp(8) + } + background = getDrawable(R.drawable.player_overlay_option_background) + setTextColor(getColorStateList(R.color.player_overlay_option_text)) + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(18), 0, dp(18), 0) + text = if (selected) "✓ $label" else " $label" + textSize = 16f + typeface = Typeface.create("sans-serif", if (selected) Typeface.BOLD else Typeface.NORMAL) + isFocusable = true + isClickable = true + isSelected = selected + setOnClickListener { onClick() } + } + + private fun hideSubtitleOverlay() { + subtitleOverlay?.visibility = View.GONE + playerView?.showController() + } + + private fun subtitleDisplayLabel(subtitle: PlayableSubtitle): String { + val name = subtitle.label?.takeIf(String::isNotBlank) + ?: subtitle.language?.let { Locale.forLanguageTag(it).displayLanguage } + ?: "Subtitles" + val flags = buildList { + if (subtitle.isForced) add("Forced") + if (subtitle.isDefault) add("Default") + if (subtitle.isHearingImpaired) add("SDH") + } + return if (flags.isEmpty()) name else "$name · ${flags.joinToString(" · ")}" + } + + private fun selectEncodedSubtitle(subtitle: PlayableSubtitle) { + val playback = player ?: return + val id = itemId?.takeIf(String::isNotBlank) ?: return + val index = subtitle.id.toIntOrNull() ?: return + val position = playback.currentPosition.coerceAtLeast(0L) + hideSubtitleOverlay() + reportProgress(position, !playback.isPlaying, "SubtitleTrackChange") + showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…") + lifecycleScope.launch { + runCatching { + ServiceLocator.repository.selectEncodedSubtitle( + playbackSession(id), + index, + playbackTitle, + position, + ) + }.onSuccess { selected -> + mediaSourceId = selected.mediaSourceId + playSessionId = selected.playSessionId + playMethod = selected.playMethod + availableSubtitles = selected.subtitles + encodedSubtitleId = subtitle.id + subtitlePreference = true + subtitleAutoSelectionAttempted = true + playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position) + playback.playWhenReady = true + playback.prepare() + }.onFailure { + showPlaybackError( + PlaybackFailure( + title = "Couldn’t enable subtitles", + detail = "The Emby server could not prepare this image subtitle track.", + canAutoRetry = false, + ), + ) + } + } + } + + private fun dp(value: Int): Int = + (value * resources.displayMetrics.density).toInt() + + @OptIn(UnstableApi::class) private fun showTrackPicker(trackType: Int) { + if (trackType == C.TRACK_TYPE_TEXT) { + showSubtitleOverlay() + return + } val playback = player ?: return val entries = mutableListOf() playback.currentTracks.groups.forEach { group -> @@ -273,37 +1376,100 @@ class PlayerActivity : ComponentActivity() { } } val choices = if (trackType == C.TRACK_TYPE_TEXT) listOf(TrackChoice(null, -1, "Off")) + entries else entries - AlertDialog.Builder(this) + val selected = choices.indexOfFirst { choice -> + choice.group != null && playback.currentTracks.groups.any { current -> + current.mediaTrackGroup == choice.group && current.isTrackSelected(choice.index) + } + }.let { if (it >= 0) it else if (trackType == C.TRACK_TYPE_TEXT) 0 else -1 } + val dialog = AlertDialog.Builder(this) .setTitle(if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles") - .setItems(choices.map { it.label }.toTypedArray()) { _, which -> + .setSingleChoiceItems(choices.map { it.label }.toTypedArray(), selected) { dialog, which -> val choice = choices[which] val builder = playback.trackSelectionParameters.buildUpon().clearOverridesOfType(trackType) - if (choice.group != null) builder.setOverrideForType(TrackSelectionOverride(choice.group, listOf(choice.index))) + builder.setTrackTypeDisabled(trackType, choice.group == null) + if (choice.group != null) { + builder.setOverrideForType(TrackSelectionOverride(choice.group, listOf(choice.index))) + } playback.trackSelectionParameters = builder.build() + if (trackType == C.TRACK_TYPE_AUDIO) { + reportProgress(playback.currentPosition, !playback.isPlaying, "AudioTrackChange") + } + if (trackType == C.TRACK_TYPE_TEXT) { + subtitlePreference = choice.group != null + subtitleAutoSelectionAttempted = true + } + dialog.dismiss() } - .show() + dialog.show() } + @OptIn(UnstableApi::class) private fun trackLabel(group: TrackGroup, index: Int): String { val format = group.getFormat(index) - return format.label?.takeIf { it.isNotBlank() } - ?: format.language?.takeIf { it.isNotBlank() }?.replaceFirstChar { it.uppercase() } - ?: if (format.channelCount > 0) "${format.channelCount} channel audio" else "Track ${index + 1}" + val rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() } + val meaningfulLabel = rawLabel?.takeUnless { it.matches(Regex("""^\([^)]*\)$""")) } + val language = format.language?.takeIf { it.isNotBlank() }?.let { + Locale.forLanguageTag(it).displayLanguage.takeIf(String::isNotBlank) + } + val name = meaningfulLabel + ?: language?.replaceFirstChar { it.uppercase() } + ?: if (format.channelCount > 0) "${format.channelCount} channel audio" else "Subtitles" + val traits = buildList { + if (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0) add("Forced") + if (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0) add("Default") + if (format.roleFlags and C.ROLE_FLAG_CAPTION != 0) add("CC") + } + return if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}" + } + + override fun onStart() { + super.onStart() + beginContentWhenReady() + if (stoppedInBackground && playbackStarted) { + stoppedInBackground = false + stopReported = false + player?.let { reportStarted(it.currentPosition) } + } } override fun onStop() { - player?.let { if (playbackStarted) reportProgress(it.currentPosition, isPaused = true) } + player?.let { + if (playbackStarted && !stopReported) { + reportProgress(it.currentPosition, isPaused = true, eventName = "Pause") + stopReported = true + stoppedInBackground = true + itemId?.takeIf(String::isNotBlank)?.let { id -> + PlaybackStopWorker.enqueue(this, playbackSession(id), it.currentPosition) + } + } + } super.onStop() player?.pause() } override fun onDestroy() { progressJob?.cancel() + prerollTimerJob?.cancel() + prerollScheduleJob?.cancel() + castJob?.cancel() + nextUpJob?.cancel() + retryJob?.cancel() + stablePlaybackJob?.cancel() loadingAnimator?.cancel() + bufferingStartedAtMs?.let { totalBufferingMs += SystemClock.elapsedRealtime() - it } + Log.i( + PLAYBACK_LOG_TAG, + "event=session_end item=${itemId.orEmpty()} rebuffers=$bufferingCount " + + "bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}", + ) val playback = player if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) { stopReported = true - ServiceLocator.repository.enqueuePlaybackStopped(itemId!!, playback?.currentPosition ?: 0L) + PlaybackStopWorker.enqueue( + this, + playbackSession(itemId!!), + playback?.currentPosition ?: 0L, + ) } playerView?.player = null playback?.release() @@ -314,31 +1480,69 @@ class PlayerActivity : ComponentActivity() { private fun reportStarted(positionMs: Long) { val id = itemId?.takeIf { it.isNotBlank() } ?: return lifecycleScope.launch { - runCatching { ServiceLocator.repository.reportPlaybackStarted(id, positionMs) } + runCatching { + ServiceLocator.repository.reportPlaybackStarted(playbackSession(id), positionMs) + } } } - private fun reportProgress(positionMs: Long, isPaused: Boolean) { + private fun reportProgress(positionMs: Long, isPaused: Boolean, eventName: String) { val id = itemId?.takeIf { it.isNotBlank() } ?: return lifecycleScope.launch { - runCatching { ServiceLocator.repository.reportPlaybackProgress(id, positionMs, isPaused) } + runCatching { + ServiceLocator.repository.reportPlaybackProgress( + playbackSession(id), + positionMs, + isPaused, + eventName, + ) + } } } + private fun playbackSession(id: String) = PlaybackSession( + itemId = id, + mediaSourceId = mediaSourceId.ifBlank { id }, + playSessionId = playSessionId, + playMethod = playMethod, + ) + private fun startProgressReporting() { progressJob?.cancel() progressJob = lifecycleScope.launch { while (isActive) { delay(PROGRESS_INTERVAL_MS) player?.let { - reportProgress(it.currentPosition, isPaused = !it.isPlaying) + reportProgress(it.currentPosition, isPaused = !it.isPlaying, eventName = "TimeUpdate") updatePlaybackTiming(it) } } } } - private data class TrackChoice(val group: TrackGroup?, val index: Int, val label: String) + private data class TrackChoice( + val group: TrackGroup?, + val index: Int, + val label: String, + val encodedSubtitle: PlayableSubtitle? = null, + ) + + private enum class SubtitleSize(val key: String, val label: String, val fraction: Float) { + SMALL("small", "Small", 0.043f), + MEDIUM("medium", "Medium", SubtitleView.DEFAULT_TEXT_SIZE_FRACTION), + LARGE("large", "Large", 0.065f), + EXTRA_LARGE("extra_large", "Extra large", 0.078f), + } + + private enum class PictureMode( + val key: String, + val label: String, + val shortLabel: String, + ) { + AUTO("auto", "Auto · fill classic 4:3 shows", "Auto"), + ORIGINAL("original", "Original aspect ratio", "Original"), + FILL("fill", "Fill screen", "Fill"), + } companion object { private const val EXTRA_URL = "extra_url" @@ -347,6 +1551,15 @@ class PlayerActivity : ComponentActivity() { private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms" private const val EXTRA_LOGO_URL = "extra_logo_url" private const val EXTRA_REQUEST_STARTED_AT_MS = "extra_request_started_at_ms" + private const val EXTRA_SUBTITLES = "extra_subtitles" + private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id" + private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id" + private const val EXTRA_PLAY_METHOD = "extra_play_method" + private const val PLAYER_PREFERENCES = "player_preferences" + private const val SUBTITLE_SIZE_KEY = "subtitle_size" + private const val PICTURE_MODE_KEY = "picture_mode" + private const val SUBTITLE_BOTTOM_PADDING_FRACTION = 0.095f + private val subtitleJson = Json { ignoreUnknownKeys = true } fun intent( context: Context, @@ -362,6 +1575,10 @@ class PlayerActivity : ComponentActivity() { title: String?, resumePositionMs: Long = 0L, logoUrl: String? = null, + subtitles: List = emptyList(), + mediaSourceId: String = "", + playSessionId: String = "", + playMethod: String = "DirectPlay", requestStartedAtMs: Long = SystemClock.elapsedRealtime(), ): Intent = Intent(context, PlayerActivity::class.java).apply { @@ -370,17 +1587,79 @@ class PlayerActivity : ComponentActivity() { putExtra(EXTRA_TITLE, title) putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs) logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) } + if (subtitles.isNotEmpty()) putExtra(EXTRA_SUBTITLES, subtitleJson.encodeToString(subtitles)) + putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId) + putExtra(EXTRA_PLAY_SESSION_ID, playSessionId) + putExtra(EXTRA_PLAY_METHOD, playMethod) putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs) } + private fun decodeSubtitles(encoded: String?): List = + encoded?.let { runCatching { subtitleJson.decodeFromString>(it) }.getOrNull() } + .orEmpty() + + private fun mediaItem(url: String, subtitles: List): MediaItem { + val configurations = subtitles.filter { + it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank() + }.map { subtitle -> + var selectionFlags = 0 + if (subtitle.isDefault) selectionFlags = selectionFlags or C.SELECTION_FLAG_DEFAULT + if (subtitle.isForced) selectionFlags = selectionFlags or C.SELECTION_FLAG_FORCED + MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitle.url)) + .setId(subtitle.id.ifBlank { null }) + .setMimeType(subtitle.mimeType) + .setLanguage(subtitle.language) + .setLabel(subtitle.label) + .setSelectionFlags(selectionFlags) + .setRoleFlags(if (subtitle.isHearingImpaired) C.ROLE_FLAG_CAPTION else 0) + .build() + } + return MediaItem.Builder() + .setUri(url) + .setSubtitleConfigurations(configurations) + .build() + } + private const val PROGRESS_INTERVAL_MS = 10_000L + private const val PREROLL_SECONDS = 5 + private const val MAX_CAST_MEMBERS = 16 + private val PREROLL_BLOCKED_KEYS = setOf( + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE, + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_SETTINGS, + ) + + /** Show against the still-playing final minute, using media time for determinism. */ + private const val NEXT_UP_LEAD_MS = 60_000L + private const val NEXT_UP_TICK_MS = 250L + private const val NEXT_UP_ANIMATION_MS = 260L + private const val NEXT_UP_VIDEO_SCALE = 0.58f + private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f + private const val NEXT_UP_VIDEO_SHIFT_Y = 0.15f + private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640 + private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360 private const val CONTROLLER_TIMEOUT_MS = 6_000 private const val MIN_BUFFER_MS = 10_000 private const val MAX_BUFFER_MS = 30_000 private const val BUFFER_FOR_PLAYBACK_MS = 750 private const val BUFFER_AFTER_REBUFFER_MS = 1_500 + private const val FRESH_STREAM_RETRY_ATTEMPT = 2 + private const val STABLE_PLAYBACK_RESET_MS = 30_000L private const val PLAYBACK_LOG_TAG = "MembyPlayback" + private fun playbackStateName(state: Int): String = + when (state) { + Player.STATE_IDLE -> "idle" + Player.STATE_BUFFERING -> "buffering" + Player.STATE_READY -> "ready" + Player.STATE_ENDED -> "ended" + else -> "unknown" + } + internal fun formatRemaining(durationMs: Long): String { val totalMinutes = ceil(durationMs.coerceAtLeast(0L) / 60_000.0).toLong() return when { @@ -392,3 +1671,21 @@ class PlayerActivity : ComponentActivity() { } } } + +internal fun prerollCanHandOff( + active: Boolean, + minimumElapsed: Boolean, + lifecycleStarted: Boolean, +): Boolean = active && minimumElapsed && lifecycleStarted + +internal fun shouldZoomVideo( + modeKey: String, + width: Int, + height: Int, + pixelWidthHeightRatio: Float, +): Boolean { + if (modeKey == "fill") return true + if (modeKey != "auto" || width <= 0 || height <= 0) return false + val displayAspectRatio = width.toFloat() * pixelWidthHeightRatio / height.toFloat() + return displayAspectRatio in 1.25f..1.50f +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt index 7daf6d2..605e308 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt @@ -2,7 +2,7 @@ package com.ponzischeme89.memby.ui.screensaver import android.content.Context import android.content.Intent -import android.net.Uri +import androidx.core.net.toUri /** Opens an item in Emby's installed Android or Android TV client. */ internal object EmbyAppLauncher { @@ -19,7 +19,7 @@ internal object EmbyAppLauncher { ) for (packageName in packageCandidates) { for (link in links) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + val intent = Intent(Intent.ACTION_VIEW, link.toUri()) .setPackage(packageName) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (intent.resolveActivity(context.packageManager) != null) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index 720eb66..9c9c7f3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -79,6 +80,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.get import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle @@ -625,7 +627,7 @@ private fun Slideshow( /** A high-legibility, always-current clock beside the slide-progress indicator. */ @Composable private fun CurrentTime(modifier: Modifier = Modifier) { - var now by remember { mutableStateOf(System.currentTimeMillis()) } + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis() @@ -890,7 +892,7 @@ private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean { var darkPixels = 0 for (y in 0 until bitmap.height step 2) { for (x in 0 until bitmap.width step 2) { - val pixel = bitmap.getPixel(x, y) + val pixel = bitmap[x, y] if (android.graphics.Color.alpha(pixel) < 48) continue opaquePixels++ val luminance = ( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 10b6358..961fd14 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -7,10 +7,10 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -21,12 +21,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -44,6 +50,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -51,55 +58,90 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Button import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.ui.PreviewSurface +import com.ponzischeme89.memby.ui.TvPreview import com.ponzischeme89.memby.update.UpdateChecker import com.ponzischeme89.memby.update.UpdateStatus import kotlinx.coroutines.delay import kotlinx.coroutines.launch -private data class SpinnerOption(val label: String, val hex: String, val color: Color) +private data class ChoiceOption(val value: String, val label: String, val color: Color? = null) -private val spinnerOptions = listOf( - SpinnerOption("White", "FFFFFF", Color.White), - SpinnerOption("Emby green", "52B54B", Color(0xFF52B54B)), - SpinnerOption("Netflix red", "E50914", Color(0xFFE50914)), +private val RingOptions = listOf( + ChoiceOption("FFFFFF", "White", Color.White), + ChoiceOption("52B54B", "Memby green", Color(0xFF52B54B)), + ChoiceOption("E50914", "Cinema red", Color(0xFFE50914)), ) -private val Muted = Color(0xFF9AA3AC) -private val Faint = Color(0xFFB9C0C7) -private val SettingsSurface = Color(0xFF1A1A1A) -private val FocusSurface = Color(0xFF3D3D3D) +private val DensityOptions = listOf( + ChoiceOption("compact", "Compact"), + ChoiceOption("standard", "Standard"), + ChoiceOption("large", "Large"), +) + +private val EmbyGreen = Color(0xFF52B54B) +private val Canvas = Color(0xFF090B0D) +private val Panel = Color(0xFF101418) +private val SectionSurface = Color(0xFF151B20) +private val RowFocused = Color(0xFF20282F) +private val ControlIdle = Color(0xFF252D34) +private val TextPrimary = Color(0xFFF2F5F7) +private val TextSecondary = Color(0xFFC2CBD2) +private val TextQuiet = Color(0xFF8F9AA3) +private val Hairline = Color.White.copy(alpha = 0.09f) + +internal data class SettingsPanelState( + val showLogo: Boolean = true, + val autoPlayNext: Boolean = true, + val ringColor: String = "52B54B", + val homeSections: Set = setOf("continue", "favorites", "latest"), + val cardDensity: String = "standard", + val showCardMetadata: Boolean = true, + val editableServer: Boolean = true, + val baseUrl: String = "", + val repoPath: String = "", + val token: String = "", + val checking: Boolean = false, + val updateStatus: UpdateStatus? = null, + val installMessage: String? = null, + val installedVersion: String = "", +) + +internal data class SettingsPanelActions( + val onClose: () -> Unit = {}, + val onShowLogoChanged: (Boolean) -> Unit = {}, + val onAutoPlayNextChanged: (Boolean) -> Unit = {}, + val onRingColorChanged: (String) -> Unit = {}, + val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> }, + val onCardDensityChanged: (String) -> Unit = {}, + val onShowCardMetadataChanged: (Boolean) -> Unit = {}, + val onBaseUrlChanged: (String) -> Unit = {}, + val onRepoPathChanged: (String) -> Unit = {}, + val onTokenChanged: (String) -> Unit = {}, + val onCheckForUpdates: () -> Unit = {}, + val onInstallUpdate: (UpdateStatus.Available) -> Unit = {}, +) /** - * A polished settings panel that slides out from the right edge over a dimming scrim. - * Shared by the home screen and the in-slideshow overlay so both look and behave the - * same. It reads/writes the shared [Settings] via [ServiceLocator]. - * - * Hosts own the Back key (this composable can't assume an OnBackPressedDispatcher — the - * DreamService has none): the home screen wraps it in a BackHandler, the slideshow - * closes it from its own key handler. A visible "Close" button is always provided too. - * - * @param editableServer when true, shows the Gitea URL/repo/token fields (home screen). - * The in-slideshow overlay passes false, since a soft keyboard isn't usable there. - * @param overlay true when shown over a slide; false when it is the launcher activity. - * @param onInstallerLaunched called after Android's package installer has been opened. - * A Dream host uses this to release its window before the APK replacement kills it. + * Settings is shared by the launcher and Dream overlay. State remains in the shared + * SettingsStore; [SettingsPanelContent] is deliberately stateless so the exact running + * UI can also be rendered in TV previews and screenshot tests. */ @Composable fun SettingsSheet( editableServer: Boolean, onClose: () -> Unit, + modifier: Modifier = Modifier, overlay: Boolean = true, onInstallerLaunched: (() -> Unit)? = null, - modifier: Modifier = Modifier, ) { val context = LocalContext.current val store = ServiceLocator.settings @@ -108,21 +150,18 @@ fun SettingsSheet( val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY) var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } + var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) } var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) } var baseUrl by rememberSaveable { mutableStateOf(settings.updateBaseUrl.orEmpty()) } var repoPath by rememberSaveable { mutableStateOf(settings.updateRepo.orEmpty()) } var token by rememberSaveable { mutableStateOf(settings.updateToken.orEmpty()) } - var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',')) } + var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) } var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) } var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) } - var checking by remember { mutableStateOf(false) } var status by remember { mutableStateOf(null) } var installMessage by remember { mutableStateOf(null) } - // DataStore arrives after the first composition. Mirror its snapshot into the - // editable state so opening the panel always shows the user's actual choices, - // rather than the empty/default placeholder used while it loads. LaunchedEffect( settings.showTitleLogo, settings.ringColorHex, @@ -132,13 +171,15 @@ fun SettingsSheet( settings.homeSections, settings.homeCardDensity, settings.showHomeCardMetadata, + settings.autoPlayNextEpisode, ) { showLogo = settings.showTitleLogo + autoPlayNext = settings.autoPlayNextEpisode ringColor = settings.ringColorHex baseUrl = settings.updateBaseUrl.orEmpty() repoPath = settings.updateRepo.orEmpty() token = settings.updateToken.orEmpty() - homeSections = settings.homeSections.split(',') + homeSections = settings.homeSections.split(',').toSet() cardDensity = settings.homeCardDensity showCardMetadata = settings.showHomeCardMetadata } @@ -147,28 +188,104 @@ fun SettingsSheet( var shown by remember { mutableStateOf(false) } LaunchedEffect(Unit) { shown = true - // AnimatedVisibility does not attach its child until the following frame. Waiting - // for the entrance transition means focus moves from the slide to this panel - // reliably on every remote, rather than silently failing on an unattached node. delay(170) runCatching { firstFocus.requestFocus() } } + val state = SettingsPanelState( + showLogo = showLogo, + autoPlayNext = autoPlayNext, + ringColor = ringColor, + homeSections = homeSections, + cardDensity = cardDensity, + showCardMetadata = showCardMetadata, + editableServer = editableServer, + baseUrl = baseUrl, + repoPath = repoPath, + token = token, + checking = checking, + updateStatus = status, + installMessage = installMessage, + installedVersion = checker.installedVersion, + ) + val actions = SettingsPanelActions( + onClose = onClose, + onShowLogoChanged = { + showLogo = it + scope.launch { store.setShowTitleLogo(it) } + }, + onAutoPlayNextChanged = { + autoPlayNext = it + scope.launch { store.setAutoPlayNextEpisode(it) } + }, + onRingColorChanged = { + ringColor = it + scope.launch { store.setRingColor(it) } + }, + onHomeSectionChanged = { key, enabled -> + homeSections = if (enabled) homeSections + key else homeSections - key + scope.launch { store.setHomeSections(homeSections.toList()) } + }, + onCardDensityChanged = { + cardDensity = it + scope.launch { store.setHomeCardDensity(it) } + }, + onShowCardMetadataChanged = { + showCardMetadata = it + scope.launch { store.setShowHomeCardMetadata(it) } + }, + onBaseUrlChanged = { baseUrl = it; status = null }, + onRepoPathChanged = { repoPath = it; status = null }, + onTokenChanged = { token = it; status = null }, + onCheckForUpdates = { + if (!checking) { + checking = true + status = null + installMessage = null + scope.launch { + if (editableServer) store.setUpdateConfig(baseUrl, repoPath, token) + status = checker.check( + settings.updateBaseUrl.orEmpty().ifEmpty { baseUrl }, + settings.updateRepo.orEmpty().ifEmpty { repoPath }, + settings.updateToken.orEmpty().ifEmpty { token }, + ) + checking = false + } + } + }, + onInstallUpdate = { available -> + installMessage = "Downloading update…" + scope.launch { + val result = checker.downloadAndInstall( + available.apkUrl, + token.ifEmpty { settings.updateToken.orEmpty() }, + ) + result.exceptionOrNull()?.let { + installMessage = it.message + } ?: run { + installMessage = "Opening the installer…" + onInstallerLaunched?.invoke() + } + } + }, + ) + Box(modifier = modifier.fillMaxSize()) { if (overlay) { - // Scrim dims the slide behind the panel, while leaving its artwork visible. Box( Modifier .fillMaxSize() - .background(Color(0x99000000)) + .background(Color(0xB3000000)) .clickable( - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + interactionSource = remember { + androidx.compose.foundation.interaction.MutableInteractionSource() + }, indication = null, onClick = onClose, - ) + ), ) } else { - Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11))) + Box(Modifier.fillMaxSize().background(Canvas)) } AnimatedVisibility( @@ -176,175 +293,406 @@ fun SettingsSheet( enter = slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150)), modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center), ) { - Column( - modifier = Modifier - .width(if (overlay) 620.dp else 900.dp) - .fillMaxHeight() - .background(SettingsSurface) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 52.dp, vertical = 44.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Settings", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.weight(1f)) - Button(onClick = onClose) { - Icon(Icons.Default.Close, contentDescription = null) - } - } + SettingsPanelContent( + state = state, + actions = actions, + overlay = overlay, + firstFocusRequester = firstFocus, + ) + } + } +} - SectionLabel("APPEARANCE") - - TvSettingsRow( - onClick = { - showLogo = !showLogo - scope.launch { store.setShowTitleLogo(showLogo) } - }, - modifier = Modifier.fillMaxWidth().focusRequester(firstFocus), - title = "Title logos", - description = "Use Emby artwork when available", - value = if (showLogo) "On" else "Off", - ) - - Text("Progress ring colour", color = Faint, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - spinnerOptions.forEach { opt -> - Button( - onClick = { - ringColor = opt.hex - scope.launch { store.setRingColor(opt.hex) } - }, - ) { Text(if (ringColor.equals(opt.hex, ignoreCase = true)) "✓ ${opt.label}" else opt.label) } - } - } - - Divider() - SectionLabel("HOME SCREEN") - Text("Choose the rows shown on your home screen", color = Muted, fontSize = 14.sp) - listOf("continue" to "Continue watching", "favorites" to "Favorites", "latest" to "Latest movies").forEach { (key, label) -> - TvSettingsRow( - onClick = { - homeSections = if (key in homeSections) homeSections - key else homeSections + key - scope.launch { store.setHomeSections(homeSections) } - }, - title = label, - description = "Show this row on the home screen", - value = if (key in homeSections) "On" else "Off", - ) - } - Text("Card size", color = Faint, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - listOf("compact" to "Compact", "standard" to "Standard", "large" to "Large").forEach { (value, label) -> - Button(onClick = { - cardDensity = value - scope.launch { store.setHomeCardDensity(value) } - }) { Text(if (cardDensity == value) "✓ $label" else label) } - } - } - TvSettingsRow( - onClick = { - showCardMetadata = !showCardMetadata - scope.launch { store.setShowHomeCardMetadata(showCardMetadata) } - }, - title = "Card details", - description = "Show episode, runtime, and resume information", - value = if (showCardMetadata) "On" else "Off", - ) - - Divider() - SectionLabel("UPDATES") - - if (editableServer) { - SheetTextField( - // A .json URL is a static manifest (a file on a NAS or web - // server); anything else is treated as a Gitea host. - label = "Update URL (…/latest.json, or a Gitea host)", - value = baseUrl, - onValueChange = { baseUrl = it; status = null }, - keyboardType = KeyboardType.Uri, - ) - SheetTextField( - label = "Repository (owner/repo) — Gitea only", - value = repoPath, - onValueChange = { repoPath = it; status = null }, - ) - SheetTextField( - label = "Access token (optional)", - value = token, - onValueChange = { token = it; status = null }, - isPassword = true, - ) +@Composable +internal fun SettingsPanelContent( + state: SettingsPanelState, + actions: SettingsPanelActions, + overlay: Boolean, + modifier: Modifier = Modifier, + firstFocusRequester: FocusRequester? = null, +) { + Column( + modifier = modifier + .width(if (overlay) 640.dp else 900.dp) + .fillMaxHeight() + .background(Panel) + .then( + if (overlay) { + Modifier.border(1.dp, Color.White.copy(alpha = 0.10f)) } else { + Modifier + .padding(vertical = 18.dp) + .clip(RoundedCornerShape(22.dp)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(22.dp)) + }, + ) + .verticalScroll(rememberScrollState()) + .padding(horizontal = if (overlay) 34.dp else 44.dp, vertical = 30.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + SettingsHeader( + version = state.installedVersion, + onClose = actions.onClose, + ) + + SettingsSection( + title = "Appearance", + description = "Artwork and playback accents", + icon = Icons.Default.Palette, + ) { + SettingsToggleRow( + title = "Title artwork", + description = "Use each film or show logo when artwork is available.", + checked = state.showLogo, + onCheckedChange = actions.onShowLogoChanged, + modifier = firstFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier, + ) + SettingDivider() + SettingsChoiceRow( + title = "Progress ring", + description = "Colour used for resume and countdown progress.", + options = RingOptions, + selected = state.ringColor, + onSelected = actions.onRingColorChanged, + ) + } + + SettingsSection( + title = "Playback", + description = "What happens at the end of an episode", + icon = Icons.Default.PlayArrow, + ) { + SettingsToggleRow( + title = "Play the next episode", + description = "Show a countdown near the end, then continue automatically.", + checked = state.autoPlayNext, + onCheckedChange = actions.onAutoPlayNextChanged, + ) + } + + SettingsSection( + title = "Home screen", + description = "Choose what appears when Memby opens", + icon = Icons.Default.Home, + ) { + listOf( + "continue" to ("Continue watching" to "Resume films and episodes in progress."), + "favorites" to ("Favourites" to "Keep starred films and shows close by."), + "latest" to ("Latest movies" to "Show recently added films."), + ).forEachIndexed { index, (key, copy) -> + if (index > 0) SettingDivider() + SettingsToggleRow( + title = copy.first, + description = copy.second, + checked = key in state.homeSections, + onCheckedChange = { actions.onHomeSectionChanged(key, it) }, + ) + } + SettingDivider() + SettingsChoiceRow( + title = "Card size", + description = "How much content fits across each row.", + options = DensityOptions, + selected = state.cardDensity, + onSelected = actions.onCardDensityChanged, + ) + SettingDivider() + SettingsToggleRow( + title = "Card details", + description = "Show episode, runtime and resume information below artwork.", + checked = state.showCardMetadata, + onCheckedChange = actions.onShowCardMetadataChanged, + ) + } + + SettingsSection( + title = "Updates", + description = "Keep this TV on the current Memby release", + icon = Icons.Default.SystemUpdate, + ) { + if (state.editableServer) { + SettingsTextField( + label = "Update source", + hint = "A latest.json URL or Gitea host", + value = state.baseUrl, + onValueChange = actions.onBaseUrlChanged, + keyboardType = KeyboardType.Uri, + ) + Spacer(Modifier.height(10.dp)) + SettingsTextField( + label = "Repository", + hint = "owner/repository — Gitea only", + value = state.repoPath, + onValueChange = actions.onRepoPathChanged, + ) + Spacer(Modifier.height(10.dp)) + SettingsTextField( + label = "Access token", + hint = "Optional", + value = state.token, + onValueChange = actions.onTokenChanged, + isPassword = true, + ) + Spacer(Modifier.height(12.dp)) + } else { + Text( + "Update source can be edited from the launcher.", + color = TextSecondary, + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + SettingDivider() + } + + SettingsActionRow( + title = if (state.checking) "Checking for updates…" else "Check for updates", + description = "Installed version ${state.installedVersion}", + badge = if (state.checking) "WORKING" else "CHECK NOW", + onClick = actions.onCheckForUpdates, + ) + + when (val update = state.updateStatus) { + is UpdateStatus.UpToDate -> SettingsNotice( + text = "You’re up to date · ${update.version}", + positive = true, + ) + is UpdateStatus.Error -> SettingsNotice(update.message, positive = false) + is UpdateStatus.Available -> { + SettingsNotice("Version ${update.version} is ready", positive = true) + if (update.notes.isNotBlank()) { + Text( + update.notes, + color = TextSecondary, + fontSize = 13.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + SettingsActionRow( + title = "Download and install", + description = "Android will ask for confirmation.", + badge = "INSTALL", + onClick = { actions.onInstallUpdate(update) }, + ) + } + null -> Unit + } + state.installMessage?.let { SettingsNotice(it, positive = true) } + } + + SettingsSection( + title = "About", + description = "Memby for Android TV", + icon = Icons.Default.Info, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { Text( - "Set the update server on the home screen to check for updates here.", - color = Muted, + stringResource(R.string.app_name), + color = TextPrimary, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + Text( + "Made by ${stringResource(R.string.developer_name)}", + color = TextSecondary, fontSize = 13.sp, ) } - - TvSettingsRow( - onClick = { - if (!checking) { - checking = true - status = null - installMessage = null - scope.launch { - if (editableServer) store.setUpdateConfig(baseUrl, repoPath, token) - val s = settings - status = checker.check( - s.updateBaseUrl.orEmpty().ifEmpty { baseUrl }, - s.updateRepo.orEmpty().ifEmpty { repoPath }, - s.updateToken.orEmpty().ifEmpty { token }, - ) - checking = false - } - } - }, - title = "Check for updates", - description = "Installed version ${checker.installedVersion}", - value = if (checking) "Checking…" else "", - ) - - when (val s = status) { - is UpdateStatus.UpToDate -> - Text("You're on the latest version (${s.version}).", color = Color(0xFF7BD88F), fontSize = 14.sp) - is UpdateStatus.Error -> Text(s.message, color = Color(0xFFFF6B6B), fontSize = 14.sp) - is UpdateStatus.Available -> { - Text( - "Update available: ${s.version}", - color = Color(0xFF7BD88F), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - ) - if (s.notes.isNotBlank()) Text(s.notes, color = Faint, fontSize = 13.sp) - Button( - onClick = { - installMessage = "Downloading update…" - scope.launch { - val result = checker.downloadAndInstall(s.apkUrl, token.ifEmpty { settings.updateToken.orEmpty() }) - result.exceptionOrNull()?.let { - installMessage = it.message - } ?: run { - installMessage = "Opening the installer…" - // The installer is now foreground. Stop an active Dream so - // it cannot retain a black system window during replacement. - onInstallerLaunched?.invoke() - } - } - }, - ) { Text("Download & install") } - } - null -> {} - } - installMessage?.let { Text(it, color = Faint, fontSize = 13.sp) } - - Divider() - SectionLabel("ABOUT") Text( - "${stringResource(R.string.app_name)} ${checker.installedVersion} · by " + - stringResource(R.string.developer_name), - color = Muted, + "v${state.installedVersion}", + color = EmbyGreen, fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(Modifier.height(12.dp)) + } +} + +@Composable +private fun SettingsHeader(version: String, onClose: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + "MEMBY · TV", + color = EmbyGreen, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.8.sp, + ) + Text("Settings", color = TextPrimary, fontSize = 32.sp, fontWeight = FontWeight.Bold) + Text( + "Make Memby feel right for this screen.", + color = TextSecondary, + fontSize = 14.sp, + ) + } + Spacer(Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("v$version", color = TextQuiet, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + SettingsIconButton(onClick = onClose) + } + } +} + +@Composable +private fun SettingsIconButton(onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(if (focused) Color.White else ControlIdle) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) Color.White else Hairline, + shape = CircleShape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close settings", + tint = if (focused) Canvas else TextPrimary, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun SettingsSection( + title: String, + description: String, + icon: ImageVector, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(9.dp)) + .background(EmbyGreen.copy(alpha = 0.13f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = EmbyGreen, modifier = Modifier.size(18.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(title, color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.Bold) + Text(description, color = TextQuiet, fontSize = 12.sp) + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SectionSurface) + .border(1.dp, Hairline, RoundedCornerShape(14.dp)) + .padding(6.dp), + content = content, + ) + } +} + +@Composable +private fun SettingsToggleRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (focused) RowFocused else Color.Transparent) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent, + shape = RoundedCornerShape(10.dp), + ) + .onFocusChanged { focused = it.isFocused } + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(description, color = TextSecondary, fontSize = 13.sp, maxLines = 2) + } + StatusToggle(checked = checked) + } +} + +@Composable +private fun StatusToggle(checked: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Text( + if (checked) "ON" else "OFF", + color = if (checked) EmbyGreen else TextQuiet, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Box( + modifier = Modifier + .width(46.dp) + .height(25.dp) + .clip(CircleShape) + .background(if (checked) EmbyGreen else Color(0xFF394149)) + .padding(3.dp), + ) { + Box( + modifier = Modifier + .align(if (checked) Alignment.CenterEnd else Alignment.CenterStart) + .size(19.dp) + .clip(CircleShape) + .background(if (checked) Color(0xFF072108) else Color(0xFFCBD2D7)), + ) + } + } +} + +@Composable +private fun SettingsChoiceRow( + title: String, + description: String, + options: List, + selected: String, + onSelected: (String) -> Unit, +) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(description, color = TextSecondary, fontSize = 13.sp) + } + Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) { + options.forEach { option -> + SettingsChoiceChip( + option = option, + selected = selected.equals(option.value, ignoreCase = true), + onClick = { onSelected(option.value) }, ) } } @@ -352,71 +700,188 @@ fun SettingsSheet( } @Composable -private fun SectionLabel(text: String) { - Text(text, color = Muted, fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp) -} - -@Composable -private fun Divider() { - Box(Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f))) -} - -/** A restrained Android TV-style preference row with a clear remote-focus state. */ -@Composable -private fun TvSettingsRow( - title: String, - description: String, - value: String, +private fun SettingsChoiceChip( + option: ChoiceOption, + selected: Boolean, onClick: () -> Unit, - modifier: Modifier = Modifier, ) { var focused by remember { mutableStateOf(false) } Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .background(if (focused) FocusSurface else Color.Transparent) + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background( + when { + selected -> EmbyGreen + focused -> RowFocused + else -> ControlIdle + }, + ) + .border( + width = if (focused) 2.dp else 1.dp, + color = when { + focused -> Color.White + selected -> EmbyGreen + else -> Color.White.copy(alpha = 0.12f) + }, + shape = RoundedCornerShape(999.dp), + ) .onFocusChanged { focused = it.isFocused } - .focusable() .clickable(onClick = onClick) - .padding(horizontal = 22.dp, vertical = 18.dp), + .padding(horizontal = 15.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), ) { - Column(Modifier.weight(1f)) { - Text(title, color = Color.White, fontSize = 19.sp) - Text(description, color = Muted, fontSize = 14.sp) + option.color?.let { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(it) + .border(1.dp, Color.Black.copy(alpha = 0.25f), CircleShape), + ) } - if (value.isNotBlank()) Text(value, color = Color.White, fontSize = 17.sp) + Text( + option.label, + color = if (selected) Color(0xFF062307) else TextPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) } } @Composable -private fun SheetTextField( +private fun SettingsActionRow( + title: String, + description: String, + badge: String, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (focused) RowFocused else Color.Transparent) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent, + shape = RoundedCornerShape(10.dp), + ) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(description, color = TextSecondary, fontSize = 13.sp) + } + Text( + badge, + color = if (focused) Color(0xFF062307) else EmbyGreen, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.7.sp, + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f)) + .padding(horizontal = 11.dp, vertical = 7.dp), + ) + } +} + +@Composable +private fun SettingsTextField( label: String, + hint: String, value: String, onValueChange: (String) -> Unit, isPassword: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(label, color = Muted, fontSize = 13.sp) + var focused by remember { mutableStateOf(false) } + Column( + modifier = Modifier.padding(horizontal = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(label.uppercase(), color = TextQuiet, fontSize = 10.sp, fontWeight = FontWeight.Bold) Box( modifier = Modifier .fillMaxWidth() - .border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.22f), RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.background, RoundedCornerShape(8.dp)) - .padding(horizontal = 14.dp, vertical = 12.dp), + .clip(RoundedCornerShape(9.dp)) + .background(Color(0xFF0D1114)) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) EmbyGreen else Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(9.dp), + ) + .onFocusChanged { focused = it.hasFocus } + .padding(horizontal = 14.dp, vertical = 11.dp), ) { + if (value.isEmpty()) { + Text(hint, color = TextQuiet, fontSize = 14.sp) + } BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, - textStyle = TextStyle(color = Color.White, fontSize = 18.sp), - cursorBrush = SolidColor(Color.White), - visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else keyboardType), + textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp), + cursorBrush = SolidColor(EmbyGreen), + visualTransformation = if (isPassword) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + keyboardOptions = KeyboardOptions( + keyboardType = if (isPassword) KeyboardType.Password else keyboardType, + ), modifier = Modifier.fillMaxWidth(), ) } } } + +@Composable +private fun SettingsNotice(text: String, positive: Boolean) { + Text( + text, + color = if (positive) Color(0xFF9DE29A) else Color(0xFFFF9B98), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + if (positive) EmbyGreen.copy(alpha = 0.10f) else Color(0xFFE75852).copy(alpha = 0.12f), + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) +} + +@Composable +private fun SettingDivider() { + Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).height(1.dp).background(Hairline)) +} + +@TvPreview +@Composable +private fun SettingsPanelPreview() { + PreviewSurface { + SettingsPanelContent( + state = SettingsPanelState( + showLogo = true, + autoPlayNext = true, + ringColor = "52B54B", + homeSections = setOf("continue", "favorites"), + cardDensity = "standard", + showCardMetadata = false, + editableServer = true, + baseUrl = "https://mserver.example.com/releases/latest.json", + installedVersion = "0.1.60", + ), + actions = SettingsPanelActions(), + overlay = false, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt index 3004a9b..e78c466 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt @@ -3,7 +3,8 @@ package com.ponzischeme89.memby.ui.theme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.text.font.FontFamily -import android.graphics.Typeface +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.darkColorScheme @@ -17,11 +18,23 @@ private val EmbyColors = darkColorScheme( @Composable fun MembyTheme(content: @Composable () -> Unit) { - // Android's native medium face is always available on TV, so it looks refined - // without a downloaded font or a first-render font swap. - val tvFont = FontFamily(Typeface.create("sans-serif-medium", Typeface.NORMAL)) + // Use Android's complete Roboto-backed sans family rather than pinning every label + // to sans-serif-medium. The generic family lets Compose select real regular, + // medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy + // and making body copy substantially easier to scan from TV distance. + // + // A platform family also has every script Android supports, adds no APK weight, and + // cannot flash or fail while a downloadable font is fetched. + val appTextStyle = LocalTextStyle.current.copy( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + // Slightly open tracking and leading keep small metadata and multi-line + // summaries from feeling cramped without making large headings look loose. + letterSpacing = 0.006.em, + lineHeight = 1.22.em, + ) MaterialTheme(colorScheme = EmbyColors) { - CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = tvFont)) { + CompositionLocalProvider(LocalTextStyle provides appTextStyle) { content() } } diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt index ee13c75..3662169 100644 --- a/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt @@ -2,10 +2,10 @@ package com.ponzischeme89.memby.update import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider +import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -165,7 +165,7 @@ class UpdateChecker(private val context: Context) { runCatching { val intent = Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - Uri.parse("package:${context.packageName}"), + "package:${context.packageName}".toUri(), ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } diff --git a/app/src/main/res/drawable/emby_logo.png b/app/src/main/res/drawable-nodpi/emby_logo.png similarity index 100% rename from app/src/main/res/drawable/emby_logo.png rename to app/src/main/res/drawable-nodpi/emby_logo.png diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml index 6357818..d162511 100644 --- a/app/src/main/res/drawable/app_banner.xml +++ b/app/src/main/res/drawable/app_banner.xml @@ -1,7 +1,11 @@ + Title logo Starting playback… Connecting directly to Emby + Connection interrupted + Trying the stream again… + Requesting a fresh stream from Emby… + Media server unavailable + Memby couldn’t request a fresh stream. Check that the server is online, then try again. + Try again + Back to Memby + + Reconnecting in %1$d second… + Reconnecting in %1$d seconds… + + NOW PLAYING + / + Loading duration… + Live + Back to previous screen + Hide controls + PLAYBACK OPTIONS + Subtitles + Cast + Loading cast… + No cast information is available. + Choose a track and tune the text size without leaving playback. + SUBTITLE TRACK + TEXT SIZE + BACK · CLOSE + Ends at %1$s + Starting in 5 seconds… + + Starting in %1$d second… + Starting in %1$d seconds… + + Starting now… + NEXT UP + Play now + Dismiss + Starting in %1$ds + Starting now… Memby Memby Screensaver - Memby movie & TV backdrops ponzischeme89 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 05cabff..8186a83 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,7 @@ @@ -54,9 +80,6 @@

Memby admin

connecting… - - -
@@ -71,6 +94,17 @@ +
+

For You

+
Loading…
+
+ + + + +
+
+

Maintenance

@@ -147,17 +181,29 @@

+ +
+

Live server events

+
+ + + + + + Connecting… +
+
+
Waiting for server events…
+
+
diff --git a/server/internal/api/admin_test.go b/server/internal/api/admin_test.go index dbd9a11..c572294 100644 --- a/server/internal/api/admin_test.go +++ b/server/internal/api/admin_test.go @@ -119,6 +119,41 @@ func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) { } } +func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) { + server := testServer(config.Config{}) + req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + req.Header.Set("X-Memby-Version", "0.9.1") + req.Header.Set("X-Memby-Protocol", "99") + rec := httptest.NewRecorder() + + server.handleServiceStatus(rec, req, store.Session{}) + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["compatible"] != false || body["compatibilityMessage"] == "" { + t.Fatalf("mismatch was not explicit: %v", body) + } + if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(membyProtocolVersion) { + t.Fatalf("version diagnostics missing: %v", body) + } +} + +func TestServiceStatusAcceptsCurrentProtocol(t *testing.T) { + server := testServer(config.Config{}) + req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + req.Header.Set("X-Memby-Protocol", "1") + rec := httptest.NewRecorder() + server.handleServiceStatus(rec, req, store.Session{}) + + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body["compatible"] != true || body["compatibilityMessage"] != "" { + t.Fatalf("current protocol should be compatible: %v", body) + } +} + func TestAdminIsDisabledWithoutAToken(t *testing.T) { server := testServer(config.Config{}) @@ -163,6 +198,46 @@ func TestAdminAuthRejectsAWrongToken(t *testing.T) { } } +func TestAdminPageEstablishesPersistentCookie(t *testing.T) { + server := testServer(config.Config{AdminToken: "secret"}) + req := httptest.NewRequest(http.MethodGet, "https://memby.local/admin/", nil) + rec := httptest.NewRecorder() + + server.handleAdminPage(rec, req) + + result := rec.Result() + cookies := result.Cookies() + if len(cookies) != 1 { + t.Fatalf("expected one admin cookie, got %d", len(cookies)) + } + cookie := cookies[0] + if cookie.Name != adminCookieName || cookie.Value != "secret" { + t.Fatalf("unexpected admin cookie: %#v", cookie) + } + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("admin cookie is not hardened: %#v", cookie) + } + if cookie.MaxAge <= 0 || cookie.Path != "/admin" { + t.Fatalf("admin cookie is not persistent or scoped: %#v", cookie) + } +} + +func TestAdminAuthAcceptsPersistentCookie(t *testing.T) { + server := testServer(config.Config{AdminToken: "secret"}) + handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil) + req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"}) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("the admin cookie should be accepted, got %d", rec.Code) + } +} + func TestToRowEventValidatesAndClamps(t *testing.T) { now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 178f2ad..2d13bcc 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -24,6 +24,8 @@ import ( "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/foryou" + serverlogging "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" @@ -35,9 +37,11 @@ type Server struct { store *store.Store cache *cache.Cache recommender *recommend.Engine + forYou *foryou.Service sonarr *sonarr.Client syncer syncerHandle log *slog.Logger + events *serverlogging.Buffer sonarrMu sync.Mutex recommendationBuilds recommendationBuilds @@ -52,9 +56,11 @@ type Deps struct { Store *store.Store Cache *cache.Cache Recommender *recommend.Engine + ForYou *foryou.Service Sonarr *sonarr.Client Syncer syncerHandle Log *slog.Logger + Events *serverlogging.Buffer } func New(cfg config.Config, deps Deps) *Server { @@ -67,9 +73,11 @@ func New(cfg config.Config, deps Deps) *Server { store: deps.Store, cache: deps.Cache, recommender: deps.Recommender, + forYou: deps.ForYou, sonarr: deps.Sonarr, syncer: deps.Syncer, log: deps.Log, + events: deps.Events, } } @@ -86,13 +94,19 @@ func (s *Server) Routes() http.Handler { v1.Handle("GET /v1/home", s.authed(s.handleHome)) v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver)) v1.Handle("GET /v1/search", s.authed(s.handleSearch)) + v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches)) + v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory)) v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations)) + v1.Handle("GET /v1/for-you", s.authed(s.handleForYou)) + v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll)) v1.Handle("GET /v1/update", s.authed(s.handleUpdate)) v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem)) + v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes)) v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite)) v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed)) v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback)) + v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode)) v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer)) v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport)) @@ -138,15 +152,59 @@ func (s *Server) authed(h authedFunc) http.Handler { writeError(w, http.StatusInternalServerError, "session lookup failed") return } + sess = s.captureClientIdentity(r, sess) h(w, r, sess) }) } +// captureClientIdentity makes the session the durable source of attribution. Normal API +// calls refresh it from headers; authenticated artwork requests, which can only carry a +// query token, inherit the last identity reported by that same TV. +func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) store.Session { + changed := mergeClientIdentity(r, &sess) + if changed { + if err := s.store.UpdateSessionClientIdentity( + r.Context(), sess.TokenHash, sess.ClientVersion, sess.ClientProtocol, + ); err != nil { + s.log.Warn("client identity update failed", "error", err) + } else { + s.cacheSession(r.Context(), sess) + } + } + return sess +} + +func mergeClientIdentity(r *http.Request, sess *store.Session) bool { + version := clientVersion(r) + protocol := clientProtocol(r) + changed := false + if version != "" && version != sess.ClientVersion { + sess.ClientVersion = version + changed = true + } + if protocol != "" && protocol != sess.ClientProtocol { + sess.ClientProtocol = protocol + changed = true + } + if version == "" && sess.ClientVersion != "" { + r.Header.Set("X-Memby-Version", sess.ClientVersion) + } + if protocol == "" && sess.ClientProtocol != "" { + r.Header.Set("X-Memby-Protocol", sess.ClientProtocol) + } + return changed +} + func (s *Server) withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) + // Polling the live-log endpoint must not create another live-log record and + // become a self-sustaining stream. + if r.URL.Path == "/admin/api/events" { + return + } // Path only: query strings can carry image tokens. level := requestLogLevel(r.URL.Path, rec.status) s.log.Log(r.Context(), level, "HTTP request", @@ -154,10 +212,19 @@ func (s *Server) withLogging(next http.Handler) http.Handler { "path", r.URL.Path, "status", rec.status, "duration", time.Since(start).Round(time.Millisecond), + "client_version", clientLogValue(clientVersion(r)), + "client_protocol", clientLogValue(clientProtocol(r)), ) }) } +func clientLogValue(value string) string { + if value == "" { + return "unknown" + } + return value +} + // Successful high-frequency probes and artwork fetches stay available at DEBUG without // overwhelming the normal Docker log. Failures are always promoted so they remain // visible regardless of path. @@ -211,12 +278,14 @@ func newToken() (string, error) { } type cachedSession struct { - EmbyUserID string `json:"u"` - EmbyToken string `json:"t"` - Username string `json:"n"` - ServerID string `json:"s"` - DeviceID string `json:"d"` - DeviceName string `json:"dn,omitempty"` + EmbyUserID string `json:"u"` + EmbyToken string `json:"t"` + Username string `json:"n"` + ServerID string `json:"s"` + DeviceID string `json:"d"` + DeviceName string `json:"dn,omitempty"` + ClientVersion string `json:"v,omitempty"` + ClientProtocol string `json:"p,omitempty"` } // sessionFor resolves a token, using Redis to keep the hot path off Postgres. @@ -228,13 +297,15 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e var cs cachedSession if json.Unmarshal(raw, &cs) == nil { return store.Session{ - TokenHash: hash, - EmbyUserID: cs.EmbyUserID, - EmbyToken: cs.EmbyToken, - Username: cs.Username, - ServerID: cs.ServerID, - DeviceID: cs.DeviceID, - DeviceName: cs.DeviceName, + TokenHash: hash, + EmbyUserID: cs.EmbyUserID, + EmbyToken: cs.EmbyToken, + Username: cs.Username, + ServerID: cs.ServerID, + DeviceID: cs.DeviceID, + DeviceName: cs.DeviceName, + ClientVersion: cs.ClientVersion, + ClientProtocol: cs.ClientProtocol, }, nil } } @@ -248,16 +319,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e return store.Session{}, store.ErrNotFound } - if raw, err := json.Marshal(cachedSession{ - EmbyUserID: sess.EmbyUserID, - EmbyToken: sess.EmbyToken, - Username: sess.Username, - ServerID: sess.ServerID, - DeviceID: sess.DeviceID, - DeviceName: sess.DeviceName, - }); err == nil { - _ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL) - } + s.cacheSession(ctx, sess) // Best-effort activity stamp; a failure here must not fail the request. if err := s.store.Touch(ctx, hash); err != nil { s.log.Warn("touch session failed", "error", err) @@ -265,6 +327,26 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e return sess, nil } +func (s *Server) cacheSession(ctx context.Context, sess store.Session) { + if raw, err := json.Marshal(cachedSession{ + EmbyUserID: sess.EmbyUserID, + EmbyToken: sess.EmbyToken, + Username: sess.Username, + ServerID: sess.ServerID, + DeviceID: sess.DeviceID, + DeviceName: sess.DeviceName, + ClientVersion: sess.ClientVersion, + ClientProtocol: sess.ClientProtocol, + }); err == nil { + _ = s.cache.Set( + ctx, + cache.SessionKey(hex.EncodeToString(sess.TokenHash)), + raw, + s.cfg.SessionTTL, + ) + } +} + func credentials(sess store.Session) emby.Credentials { return emby.Credentials{ UserID: sess.EmbyUserID, Token: sess.EmbyToken, diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index 3adc924..93c76b1 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -5,9 +5,11 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/store" ) func TestBearerTokenSources(t *testing.T) { @@ -116,6 +118,55 @@ func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) { } } +func TestEpisodeAfterUsesPositionNotListLength(t *testing.T) { + episode := func(id string) json.RawMessage { + return json.RawMessage(`{"Id":"` + id + `","Name":"Episode ` + id + `","SeriesName":"Westworld"}`) + } + + t.Run("middle of a season", func(t *testing.T) { + items := []json.RawMessage{episode("1"), episode("2"), episode("3")} + raw, next, ok := episodeAfter(items, "2") + if !ok { + t.Fatal("expected an episode after 2") + } + if next.ID != "3" { + t.Fatalf("next = %q, want 3", next.ID) + } + if got := seriesNameOf(raw); got != "Westworld" { + t.Fatalf("series name = %q, want Westworld", got) + } + }) + + // Emby drops the leading entry for the first episode, so a two-item list can mean + // either "first, second" or "second-to-last, last" depending on where we are. + t.Run("first episode has no previous", func(t *testing.T) { + items := []json.RawMessage{episode("1"), episode("2")} + if _, next, ok := episodeAfter(items, "1"); !ok || next.ID != "2" { + t.Fatalf("next = %+v, ok = %v, want episode 2", next, ok) + } + }) + + t.Run("series finale has nothing after it", func(t *testing.T) { + items := []json.RawMessage{episode("1"), episode("2")} + if _, _, ok := episodeAfter(items, "2"); ok { + t.Fatal("the last episode must not resolve a next one") + } + }) + + t.Run("current episode missing from the result", func(t *testing.T) { + items := []json.RawMessage{episode("7"), episode("8")} + if _, _, ok := episodeAfter(items, "42"); ok { + t.Fatal("an unrelated result must not resolve a next episode") + } + }) + + t.Run("empty result", func(t *testing.T) { + if _, _, ok := episodeAfter(nil, "1"); ok { + t.Fatal("no episodes must not resolve a next one") + } + }) +} + // Empty rows must serialise as [] so kotlinx.serialization can decode them into the // client's non-null List fields. func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) { @@ -137,7 +188,22 @@ func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) { } } -// The four fixed rows must keep their order, ids and kinds: the client maps kinds onto +func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) { + resp := searchHistoryResponse{Queries: []string{}} + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := decoded["queries"].([]any); !ok { + t.Fatalf("queries encoded as %T, want array", decoded["queries"]) + } +} + +// The fixed rows must keep their order, ids and kinds: the client maps kinds onto // card shapes and uses ids as Compose keys. func TestBaseRowsShape(t *testing.T) { rows := baseRows(homeResponse{ @@ -168,6 +234,29 @@ func TestBaseRowsShape(t *testing.T) { } } +func TestHomeForYouWindowAt(t *testing.T) { + tests := []struct { + hour int + id string + minutes int + }{ + {hour: 4, id: "late-night", minutes: 60}, + {hour: 5, id: "morning", minutes: 30}, + {hour: 11, id: "morning", minutes: 30}, + {hour: 12, id: "afternoon", minutes: 60}, + {hour: 16, id: "afternoon", minutes: 60}, + {hour: 17, id: "evening", minutes: 120}, + {hour: 22, id: "evening", minutes: 120}, + {hour: 23, id: "late-night", minutes: 60}, + } + for _, test := range tests { + got := homeForYouWindowAt(time.Date(2026, time.July, 29, test.hour, 0, 0, 0, time.UTC)) + if got.ID != test.id || got.Minutes != test.minutes { + t.Errorf("hour %d: got %#v, want id=%q minutes=%d", test.hour, got, test.id, test.minutes) + } + } +} + func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) { var builds recommendationBuilds @@ -200,3 +289,32 @@ func TestSummariseReadsResumePosition(t *testing.T) { t.Fatalf("resume position = %d ms, want 3600000", got) } } + +func TestMergeClientIdentityPersistsReportedHeaders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + req.Header.Set("X-Memby-Version", "0.1.60") + req.Header.Set("X-Memby-Protocol", "1") + sess := store.Session{} + + if changed := mergeClientIdentity(req, &sess); !changed { + t.Fatal("reported identity should update the session") + } + if sess.ClientVersion != "0.1.60" || sess.ClientProtocol != "1" { + t.Fatalf("session identity = %q/%q", sess.ClientVersion, sess.ClientProtocol) + } +} + +func TestMergeClientIdentityAttributesHeaderlessAuthenticatedRequest(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/images/42/primary?t=token", nil) + sess := store.Session{ClientVersion: "0.1.60", ClientProtocol: "1"} + + if changed := mergeClientIdentity(req, &sess); changed { + t.Fatal("inheriting identity should not write the session again") + } + if got := clientVersion(req); got != "0.1.60" { + t.Fatalf("inherited client version = %q", got) + } + if got := clientProtocol(req); got != "1" { + t.Fatalf("inherited client protocol = %q", got) + } +} diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 3ce48de..4cc939c 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -86,13 +86,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } sess := store.Session{ - TokenHash: hashToken(token), - EmbyUserID: auth.User.ID, - EmbyToken: auth.AccessToken, - Username: auth.User.Name, - ServerID: auth.ServerID, - DeviceID: req.DeviceID, - DeviceName: req.DeviceName, + TokenHash: hashToken(token), + EmbyUserID: auth.User.ID, + EmbyToken: auth.AccessToken, + Username: auth.User.Name, + ServerID: auth.ServerID, + DeviceID: req.DeviceID, + DeviceName: req.DeviceName, + ClientVersion: clientVersion(r), + ClientProtocol: clientProtocol(r), } if sess.Username == "" { sess.Username = req.Username @@ -132,6 +134,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if len(replacedHash) > 0 { _ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash))) } + if s.forYou != nil { + s.forYou.MarkDirty(r.Context(), sess) + s.forYou.RefreshAsync(sess, false) + } writeJSON(w, http.StatusOK, loginResponse{ Token: token, diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 5d3a63c..03be9e3 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -1,12 +1,15 @@ package api import ( + "context" "encoding/json" "math/rand/v2" "net/http" "net/url" "strconv" + "strings" "sync" + "time" "github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/cache" @@ -19,9 +22,8 @@ import ( // single biggest lever on home-screen latency, so keep these tight. const ( fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio" - fieldsNextUp = "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio" fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio" - fieldsDetail = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio" + fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio" fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks" rowImageTypes = "Backdrop,Primary,Logo" @@ -34,7 +36,7 @@ type homeResponse struct { // without touching the TV app. The client renders whatever arrives. Rows []recommend.Row `json:"rows"` - // The four fixed rows are also sent flat. They are what the client caches for an + // The fixed rows are also sent flat. They are what the client caches for an // instant cold start, and what the direct-to-Emby path still produces. ContinueWatching []json.RawMessage `json:"continueWatching"` NextUp []json.RawMessage `json:"nextUp"` @@ -64,11 +66,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S cred := credentials(sess) var ( - mu sync.Mutex - failures int - out homeResponse - sonarrRow *recommend.Row - wg sync.WaitGroup + mu sync.Mutex + failures int + out homeResponse + sonarrRow *recommend.Row + forYouRow *recommend.Row + forYouRowStale bool + wg sync.WaitGroup ) run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) { @@ -88,20 +92,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S } run(&out.ContinueWatching, func() (*emby.ItemsResult, error) { - return s.emby.Items(ctx, cred, rowParams(url.Values{ - "Filters": {"IsResumable"}, - "IncludeItemTypes": {"Movie,Episode"}, - "Recursive": {"true"}, - "SortBy": {"DatePlayed"}, - "SortOrder": {"Descending"}, - "Limit": {itoa(limit)}, + return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{ + "Recursive": {"true"}, + "MediaTypes": {"Video"}, + "Limit": {itoa(limit)}, }, fieldsContinue)) }) - run(&out.NextUp, func() (*emby.ItemsResult, error) { - return s.emby.NextUp(ctx, cred, rowParams(url.Values{ - "Limit": {itoa(limit)}, - }, fieldsNextUp)) - }) run(&out.Favorites, func() (*emby.ItemsResult, error) { return s.emby.Items(ctx, cred, rowParams(url.Values{ "Filters": {"IsFavorite"}, @@ -112,6 +108,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S "Limit": {itoa(limit)}, }, fieldsRow)) }) + run(&out.NextUp, func() (*emby.ItemsResult, error) { + return s.emby.NextUp(ctx, cred, rowParams(url.Values{ + "Limit": {itoa(limit)}, + }, fieldsContinue)) + }) run(&out.LatestMovies, func() (*emby.ItemsResult, error) { return s.emby.Items(ctx, cred, rowParams(url.Values{ "IncludeItemTypes": {"Movie"}, @@ -135,6 +136,48 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S mu.Unlock() }() } + if s.forYou != nil { + // The prepared pool is one indexed PostgreSQL read. It runs beside the Emby + // calls and deliberately has no live-engine fallback, so Home can never inherit + // Tracearr fan-out or recommendation rebuild latency. + wg.Add(1) + go func() { + defer wg.Done() + location := s.cfg.SonarrLocation + if location == nil { + location = time.UTC + } + window := homeForYouWindowAt(time.Now().In(location)) + prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes) + if err != nil { + s.log.Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err) + return + } + if !hit || len(prepared) == 0 { + return + } + rowIndex := -1 + for i := range prepared { + if prepared[i].ID == "for-you:picks" { + rowIndex = i + break + } + } + if rowIndex < 0 { + return + } + row := prepared[rowIndex] + row.ID = "for-you:home:" + window.ID + row.Title = window.Title + if len(row.Items) > 12 { + row.Items = row.Items[:12] + } + mu.Lock() + forYouRow = &row + forYouRowStale = stale + mu.Unlock() + }() + } wg.Wait() @@ -153,9 +196,21 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S s.refreshRecommendationsInBackground(sess) } rows := baseRows(out) + nearContinue := make([]recommend.Row, 0, 2) + if forYouRow != nil { + nearContinue = append(nearContinue, *forYouRow) + if forYouRowStale { + s.forYou.MarkDirty(context.WithoutCancel(ctx), sess) + s.forYou.RefreshAsync(sess, false) + } + } if sonarrRow != nil { - // The schedule is most useful beside Next Up, before personal collections. - rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...) + nearContinue = append(nearContinue, *sonarrRow) + } + if len(nearContinue) > 0 { + // Personalised discovery and today's schedule are most useful immediately + // after Continue Watching, before the broader library collections. + rows = append(rows[:1], append(nearContinue, rows[1:]...)...) } out.Rows = append(rows, recommendations...) @@ -273,7 +328,60 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store writeRaw(w, http.StatusOK, body) } -// baseRows describes the four fixed rows. +type searchHistoryRequest struct { + Query string `json:"query"` +} + +type searchHistoryResponse struct { + Queries []string `json:"queries"` +} + +const ( + recentSearchDays = 30 + recentSearchLimit = 10 +) + +func (s *Server) handleRecentSearches(w http.ResponseWriter, r *http.Request, sess store.Session) { + if s.store == nil { + writeError(w, http.StatusInternalServerError, "could not load recent searches") + return + } + since := time.Now().Add(-recentSearchDays * 24 * time.Hour) + queries, err := s.store.RecentSearches( + r.Context(), + sess.EmbyUserID, + since, + recentSearchLimit, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load recent searches") + return + } + if queries == nil { + queries = []string{} + } + writeJSON(w, http.StatusOK, searchHistoryResponse{Queries: queries}) +} + +func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) { + var req searchHistoryRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid search history request") + return + } + query := strings.TrimSpace(req.Query) + if len([]rune(query)) < 2 || len([]rune(query)) > 200 { + writeError(w, http.StatusBadRequest, "search query length is invalid") + return + } + if s.store == nil || s.store.RecordSearch(r.Context(), sess.EmbyUserID, query) != nil { + writeError(w, http.StatusInternalServerError, "could not record search") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// baseRows describes the three fixed rows. // // Titles live here rather than in the app so wording can change server-side. They are // emitted even when empty: the client draws its own "Nothing in progress" message, and a @@ -283,7 +391,29 @@ func baseRows(h homeResponse) []recommend.Row { {ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching}, {ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp}, {ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites}, - {ID: "latest-movies", Title: "Recently Added Movies", Kind: "latest", Items: h.LatestMovies}, + {ID: "latest-movies", Title: "Recent New Releases", Kind: "latest", Items: h.LatestMovies}, + } +} + +type homeForYouWindow struct { + ID string + Title string + Minutes int +} + +// homeForYouWindowAt keeps Home useful without asking the viewer for a duration. +// These deliberately broad windows suit a household TV: short before lunch, an +// episode-sized pick in the afternoon/late evening, and film headroom at night. +func homeForYouWindowAt(now time.Time) homeForYouWindow { + switch hour := now.Hour(); { + case hour >= 5 && hour < 12: + return homeForYouWindow{ID: "morning", Title: "Quick morning picks for you", Minutes: 30} + case hour >= 12 && hour < 17: + return homeForYouWindow{ID: "afternoon", Title: "An hour for your afternoon", Minutes: 60} + case hour >= 17 && hour < 23: + return homeForYouWindow{ID: "evening", Title: "Tonight's picks for you", Minutes: 120} + default: + return homeForYouWindow{ID: "late-night", Title: "Late-night picks for you", Minutes: 60} } } diff --git a/server/internal/api/images.go b/server/internal/api/images.go index f3a5e57..44d77bc 100644 --- a/server/internal/api/images.go +++ b/server/internal/api/images.go @@ -1,12 +1,16 @@ package api import ( + "context" "errors" "io" + "log/slog" + "net" "net/http" "net/url" "strconv" "strings" + "syscall" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" @@ -43,6 +47,9 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store. params.Set(key, v) } } + if writeNotModifiedForTag(w, r, params.Get("tag")) { + return + } resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params) if err != nil { @@ -61,14 +68,14 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store. // stay conservative. if params.Get("tag") != "" { w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + w.Header().Set("ETag", imageETag(params.Get("tag"))) } else { w.Header().Set("Cache-Control", "private, max-age=3600") } w.WriteHeader(http.StatusOK) - if _, err := io.Copy(w, resp.Body); err != nil { - s.log.Warn("image copy failed", "error", err) - } + copyImage(w, r, resp.Body, s.log, + "source", "emby", "item_id", itemID, "image_type", imageType) } func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) { @@ -111,7 +118,71 @@ func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemI } w.Header().Set("Cache-Control", "private, max-age=3600") w.WriteHeader(http.StatusOK) - if _, err := io.Copy(w, resp.Body); err != nil { - s.log.Warn("sonarr image copy failed", "error", err) + copyImage(w, r, resp.Body, s.log, + "source", "sonarr", "item_id", itemID, "image_type", imageType) +} + +func copyImage( + w io.Writer, + r *http.Request, + body io.Reader, + log *slog.Logger, + attributes ...any, +) { + written, err := io.Copy(w, body) + if err == nil { + return } + if expectedClientDisconnect(r, err) { + // Image loaders cancel work aggressively as cards leave the viewport. That is a + // successful resource-saving decision by the TV, not an unhealthy gateway. + return + } + fields := append([]any{"bytes_written", written, "error", err}, attributes...) + log.Warn("image stream interrupted", fields...) +} + +func expectedClientDisconnect(r *http.Request, err error) bool { + if r.Context().Err() != nil || + errors.Is(err, context.Canceled) || + errors.Is(err, net.ErrClosed) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) { + return true + } + message := strings.ToLower(err.Error()) + for _, fragment := range []string{ + "broken pipe", + "connection reset by peer", + "client disconnected", + "request canceled", + "request cancelled", + "stream closed", + } { + if strings.Contains(message, fragment) { + return true + } + } + return false +} + +func writeNotModifiedForTag(w http.ResponseWriter, r *http.Request, tag string) bool { + if tag == "" { + return false + } + etag := imageETag(tag) + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + for _, candidate := range strings.Split(r.Header.Get("If-None-Match"), ",") { + if strings.TrimSpace(candidate) == etag || strings.TrimSpace(candidate) == "*" { + w.WriteHeader(http.StatusNotModified) + return true + } + } + return false +} + +func imageETag(tag string) string { + // Emby image tags are normally hex, but quote defensively for a valid HTTP entity tag. + return `"` + strings.NewReplacer(`\`, "", `"`, "").Replace(tag) + `"` } diff --git a/server/internal/api/items.go b/server/internal/api/items.go index ed0dfd1..35ce25c 100644 --- a/server/internal/api/items.go +++ b/server/internal/api/items.go @@ -3,11 +3,16 @@ package api import ( "encoding/json" "net/http" + "net/url" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/store" ) +type seriesEpisodesResponse struct { + Items []json.RawMessage `json:"items"` +} + type flagRequest struct { Value bool `json:"value"` } @@ -21,7 +26,9 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S writeError(w, http.StatusBadRequest, "item id is required") return } - key := cache.UserKey(sess.EmbyUserID, "item:"+itemID) + // Version the entry when the detail contract grows so older cached payloads cannot + // hide newly requested fields such as People. + key := cache.UserKey(sess.EmbyUserID, "item:v2:"+itemID) if raw, err := s.cache.Get(ctx, key); err == nil { w.Header().Set("X-Memby-Cache", "hit") @@ -41,6 +48,51 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S writeRaw(w, http.StatusOK, item) } +// handleSeriesEpisodes supplies the complete episode browser in one cached response. +// The TV groups by ParentIndexNumber locally, so changing seasons never reaches Emby. +func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + seriesID := r.PathValue("id") + if seriesID == "" { + writeError(w, http.StatusBadRequest, "series id is required") + return + } + key := cache.UserKey(sess.EmbyUserID, "series-episodes:"+seriesID) + if raw, err := s.cache.Get(ctx, key); err == nil { + w.Header().Set("X-Memby-Cache", "hit") + writeRaw(w, http.StatusOK, raw) + return + } + + result, err := s.emby.Episodes(ctx, credentials(sess), seriesID, url.Values{ + "UserId": {sess.EmbyUserID}, + "Fields": {"Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"}, + "EnableUserData": {"true"}, + "EnableImages": {"true"}, + "EnableImageTypes": {"Primary,Thumb,Backdrop"}, + "ImageTypeLimit": {"1"}, + "Limit": {"1000"}, + }) + if err != nil { + s.writeUpstreamError(w, err, "could not load series episodes") + return + } + items := result.Items + if items == nil { + items = []json.RawMessage{} + } + body, err := json.Marshal(seriesEpisodesResponse{Items: items}) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not encode series episodes") + return + } + if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil { + s.log.Warn("series episodes cache write failed", "error", err) + } + w.Header().Set("X-Memby-Cache", "miss") + writeRaw(w, http.StatusOK, body) +} + // handleTrailer answers with the item's first local trailer, or 404 when it has none. // The screensaver's Play action uses this before asking for a playback URL. func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) { @@ -100,5 +152,9 @@ func (s *Server) setFlag( if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("cache invalidation failed", "error", err) } + if s.forYou != nil { + s.forYou.MarkDirty(r.Context(), sess) + s.forYou.RefreshAsync(sess, true) + } writeRaw(w, http.StatusOK, userData) } diff --git a/server/internal/api/maintenance.go b/server/internal/api/maintenance.go index 2baf563..e734481 100644 --- a/server/internal/api/maintenance.go +++ b/server/internal/api/maintenance.go @@ -89,14 +89,31 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler { // handleServiceStatus is the live control channel clients poll while the app is open. // It sits outside maintenanceGate so maintenance can interrupt playback rather than only // being discovered the next time a content request happens to run. -func (s *Server) handleServiceStatus(w http.ResponseWriter, _ *http.Request, _ store.Session) { +// It also carries informational alerts, because this poll is the one thing the app is +// already listening to — a push channel would be a second connection for a banner. +func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ store.Session) { state := s.maintenance.get() message := state.Message if state.Enabled && message == "" { message = store.DefaultMaintenanceMessage } + alerts := []clientAlert{} + // Nothing to celebrate while the service is down, and the client is showing the + // maintenance screen anyway. + if !state.Enabled { + if found := s.sonarrAiredAlerts(r.Context()); len(found) > 0 { + alerts = found + } + } + compatible, compatibilityMessage := compatibilityFor(r) writeJSON(w, http.StatusOK, map[string]any{ - "maintenance": state.Enabled, - "message": message, + "maintenance": state.Enabled, + "message": message, + "alerts": alerts, + "compatible": compatible, + "compatibilityMessage": compatibilityMessage, + "clientVersion": clientVersion(r), + "clientProtocol": clientProtocol(r), + "serverProtocol": membyProtocolVersion, }) } diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index 9ca0d14..e0727b2 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -15,16 +15,51 @@ import ( const ticksPerMillisecond = 10_000 type playbackResponse struct { - ItemID string `json:"itemId"` - Title string `json:"title"` - URL string `json:"url"` - ResumePositionMs int64 `json:"resumePositionMs"` + ItemID string `json:"itemId"` + Title string `json:"title"` + URL string `json:"url"` + ResumePositionMs int64 `json:"resumePositionMs"` + Subtitles []playableSubtitle `json:"subtitles"` + MediaSourceID string `json:"mediaSourceId"` + PlaySessionID string `json:"playSessionId"` + PlayMethod string `json:"playMethod"` +} + +type playableSubtitle struct { + ID string `json:"id"` + URL string `json:"url"` + MimeType string `json:"mimeType"` + Language string `json:"language,omitempty"` + Label string `json:"label,omitempty"` + IsDefault bool `json:"isDefault"` + IsForced bool `json:"isForced"` + IsHearingImpaired bool `json:"isHearingImpaired"` + DeliveryMethod string `json:"deliveryMethod"` + Codec string `json:"codec,omitempty"` } type playbackReport struct { - ItemID string `json:"itemId"` - PositionMs int64 `json:"positionMs"` - IsPaused bool `json:"isPaused"` + ItemID string `json:"itemId"` + PositionMs int64 `json:"positionMs"` + IsPaused bool `json:"isPaused"` + MediaSourceID string `json:"mediaSourceId"` + PlaySessionID string `json:"playSessionId"` + PlayMethod string `json:"playMethod"` + EventName string `json:"eventName,omitempty"` +} + +// nextEpisodeResponse carries the episode that follows the one being watched. Item is +// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so +// the client decodes it into the same BaseItem it uses everywhere else. +type nextEpisodeResponse struct { + Item json.RawMessage `json:"item"` + Title string `json:"title"` + URL string `json:"url"` + ResumePositionMs int64 `json:"resumePositionMs"` + Subtitles []playableSubtitle `json:"subtitles"` + MediaSourceID string `json:"mediaSourceId"` + PlaySessionID string `json:"playSessionId"` + PlayMethod string `json:"playMethod"` } // handlePlayback resolves what to actually play. @@ -74,11 +109,28 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto } } + var subtitleIndex *int + if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 { + subtitleIndex = &parsed + } + } + subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles( + ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "", + ) + streamURL := s.emby.StreamURL(cred, target.ID) + if negotiatedURL != "" { + streamURL = negotiatedURL + } writeJSON(w, http.StatusOK, playbackResponse{ ItemID: target.ID, Title: title, - URL: s.emby.StreamURL(cred, target.ID), + URL: streamURL, ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), + Subtitles: subtitles, + MediaSourceID: mediaSourceID, + PlaySessionID: playSessionID, + PlayMethod: playMethod, }) } @@ -139,6 +191,225 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials return &summary, nil } +// handleNextEpisode resolves the episode that follows the one being watched, so the player +// can offer a "next up" countdown without the TV needing to know how Emby orders a series. +// +// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an +// unreadable series all come back as 404 and the client simply shows no banner. +func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + itemID := r.PathValue("id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "item id is required") + return + } + cred := credentials(sess) + + // The client already knows which series it launched, so accepting it as a hint keeps + // this off Emby for one round trip. Older clients omit it and we look it up. + seriesID := strings.TrimSpace(r.URL.Query().Get("seriesId")) + if seriesID == "" { + raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId") + if err != nil { + s.writeUpstreamError(w, err, "could not load the item") + return + } + var parsed struct { + SeriesID string `json:"SeriesId"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + writeError(w, http.StatusBadGateway, "unreadable item from emby") + return + } + seriesID = parsed.SeriesID + } + if seriesID == "" { + writeError(w, http.StatusNotFound, "this item is not part of a series") + return + } + + episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{ + "AdjacentTo": {itemID}, + "Fields": {"RunTimeTicks,Overview,SeriesName"}, + "EnableUserData": {"true"}, + "EnableImageTypes": {"Primary,Thumb"}, + }) + if err != nil { + s.writeUpstreamError(w, err, "could not load the next episode") + return + } + + raw, next, ok := episodeAfter(episodes.Items, itemID) + if !ok { + writeError(w, http.StatusNotFound, "no episode follows this one") + return + } + + title := next.Name + if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" { + title = series + " – " + title + } + subtitles, mediaSourceID, playSessionID, _, playMethod := s.playbackSubtitles( + ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", + ) + writeJSON(w, http.StatusOK, nextEpisodeResponse{ + Item: raw, + Title: title, + URL: s.emby.StreamURL(cred, next.ID), + ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), + Subtitles: subtitles, + MediaSourceID: mediaSourceID, + PlaySessionID: playSessionID, + PlayMethod: playMethod, + }) +} + +func (s *Server) playbackSubtitles( + ctx context.Context, cred emby.Credentials, itemID string, startTicks int64, + subtitleIndex *int, currentPlaySessionID string, +) ([]playableSubtitle, string, string, string, string) { + info, err := s.emby.PlaybackInfo( + ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, + ) + if err != nil { + s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err) + return []playableSubtitle{}, itemID, "", "", "DirectPlay" + } + if len(info.MediaSources) == 0 { + return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay" + } + out := make([]playableSubtitle, 0) + seen := make(map[string]bool) + source := info.MediaSources[0] + for _, stream := range source.MediaStreams { + if !strings.EqualFold(stream.Type, "Subtitle") || stream.Index < 0 { + continue + } + method := stream.DeliveryMethod + if method == "" { + if stream.IsTextSubtitleStream { + method = "External" + } else { + method = "Encode" + } + } + delivery := "" + mimeType := subtitleMIME(stream.Codec, stream.DeliveryURL) + if strings.EqualFold(method, "External") && mimeType != "" { + if stream.DeliveryURL != "" { + delivery = s.emby.DeliveryURL(cred, stream.DeliveryURL) + } else { + delivery = s.emby.SubtitleURL(cred, itemID, source.ID, stream.Index, subtitleExtension(stream.Codec)) + } + } + key := strconv.Itoa(stream.Index) + if seen[key] { + continue + } + seen[key] = true + label := strings.TrimSpace(stream.DisplayTitle) + if label == "" { + label = strings.TrimSpace(stream.Title) + } + out = append(out, playableSubtitle{ + ID: strconv.Itoa(stream.Index), + URL: delivery, + MimeType: mimeType, + Language: strings.TrimSpace(stream.Language), + Label: label, + IsDefault: stream.IsDefault, + IsForced: stream.IsForced, + IsHearingImpaired: stream.IsHearingImpaired || + strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "sdh") || + strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "hearing"), + DeliveryMethod: method, + Codec: stream.Codec, + }) + } + negotiatedURL := "" + playMethod := "DirectPlay" + if subtitleIndex != nil && source.TranscodingURL != "" { + negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL) + playMethod = "Transcode" + } + return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod +} + +func subtitleExtension(codec string) string { + switch strings.ToLower(strings.TrimSpace(codec)) { + case "subrip": + return "srt" + case "webvtt": + return "vtt" + case "tx3g": + return "mov_text" + default: + if strings.TrimSpace(codec) == "" { + return "vtt" + } + return strings.ToLower(strings.TrimSpace(codec)) + } +} + +func subtitleMIME(codec, delivery string) string { + value := strings.ToLower(strings.TrimSpace(codec)) + if value == "" { + path := delivery + if parsed, err := url.Parse(delivery); err == nil { + path = parsed.Path + } + if dot := strings.LastIndex(path, "."); dot >= 0 { + value = strings.ToLower(path[dot+1:]) + } + } + switch value { + case "srt", "subrip": + return "application/x-subrip" + case "vtt", "webvtt": + return "text/vtt" + case "ass", "ssa": + return "text/x-ssa" + case "ttml", "dfxp": + return "application/ttml+xml" + case "tx3g", "mov_text": + return "application/x-quicktime-tx3g" + default: + return "" + } +} + +// episodeAfter picks the episode following currentID out of an AdjacentTo result, which +// Emby returns in running order as [previous, current, next] minus whichever ends do not +// exist — so the position of the current episode is what identifies the next one, not the +// length of the list. +func episodeAfter(items []json.RawMessage, currentID string) (json.RawMessage, emby.Summary, bool) { + for i, raw := range items { + summary, err := emby.Summarise(raw) + if err != nil || summary.ID != currentID { + continue + } + if i+1 >= len(items) { + return nil, emby.Summary{}, false + } + next, err := emby.Summarise(items[i+1]) + if err != nil || next.ID == "" { + return nil, emby.Summary{}, false + } + return items[i+1], next, true + } + return nil, emby.Summary{}, false +} + +func seriesNameOf(raw json.RawMessage) string { + var parsed struct { + SeriesName string `json:"SeriesName"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return "" + } + return parsed.SeriesName +} + // handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache // so Continue Watching reflects the new position on the next home load. func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) { @@ -160,8 +431,11 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se return } - err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID, - max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused) + err := s.emby.ReportPlayback( + r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID, + report.PlaySessionID, report.PlayMethod, report.EventName, + max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused, + ) if err != nil { // A dropped progress report is not worth failing playback over; log and accept. s.log.Warn("playback report failed", "phase", phase, "error", err) @@ -176,6 +450,10 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("recommendation invalidation failed", "error", err) } + if s.forYou != nil { + s.forYou.MarkDirty(r.Context(), sess) + s.forYou.RefreshAsync(sess, true) + } } w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/api/recommend.go b/server/internal/api/recommend.go index 897900a..ec95aaf 100644 --- a/server/internal/api/recommend.go +++ b/server/internal/api/recommend.go @@ -124,6 +124,46 @@ func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, s writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)}) } +// handleForYou is deliberately separate from /home. Tracearr and the richer scoring +// path may take longer than a launcher request, and the chosen time budget is local to +// this visit. The TV only calls this when the viewer enters the dedicated area. +func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store.Session) { + minutes := queryInt(r, "minutes", 0, 360) + if s.forYou != nil && r.URL.Query().Get("refresh") != "1" { + rows, hit, stale, err := s.forYou.PreparedRows(r.Context(), sess, minutes) + if err != nil { + s.log.Warn("prepared For You read failed; using live fallback", + "user", sess.EmbyUserID, "error", err) + } else if hit { + w.Header().Set("X-Memby-For-You", "prepared") + if stale { + s.forYou.MarkDirty(r.Context(), sess) + s.forYou.RefreshAsync(sess, false) + } + writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)}) + return + } else { + s.forYou.MarkDirty(r.Context(), sess) + s.forYou.RefreshAsync(sess, false) + } + } + buildCtx, cancel := context.WithTimeout(r.Context(), s.cfg.RecommendTimeout) + defer cancel() + + rows, err := s.recommender.BuildForYou( + buildCtx, + credentials(sess), + sess.Username, + recommend.ForYouOptions{AvailableMinutes: minutes}, + ) + if err != nil { + s.writeUpstreamError(w, err, "could not build For You recommendations") + return + } + w.Header().Set("X-Memby-For-You", "live-fallback") + writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)}) +} + func nonNilRows(rows []recommend.Row) []recommend.Row { if rows == nil { return []recommend.Row{} diff --git a/server/internal/api/update.go b/server/internal/api/update.go index 24df4c1..b8dbc45 100644 --- a/server/internal/api/update.go +++ b/server/internal/api/update.go @@ -3,6 +3,7 @@ package api import ( "context" "net/http" + "strconv" "strings" "sync" "time" @@ -11,6 +12,11 @@ import ( "github.com/ponzischeme89/memby/server/internal/store" ) +// membyProtocolVersion changes only when the client/server wire contract is no longer +// mutually compatible. App release versions remain independent and are handled by the +// update policy. +const membyProtocolVersion = 1 + // updatePolicyCache keeps the policy in memory. It is read on every home request, and a // database round trip per home load to answer "nothing to say" would be wasteful. type updatePolicyCache struct { @@ -63,6 +69,22 @@ func clientVersion(r *http.Request) string { return strings.TrimSpace(r.Header.Get("X-Memby-Version")) } +func clientProtocol(r *http.Request) string { + return strings.TrimSpace(r.Header.Get("X-Memby-Protocol")) +} + +func compatibilityFor(r *http.Request) (bool, string) { + reported, err := strconv.Atoi(clientProtocol(r)) + if err != nil || reported != membyProtocolVersion { + if clientProtocol(r) == "" { + return false, "This Memby app is too old to verify compatibility with the server. Update the app." + } + return false, "Memby app/server mismatch: app protocol " + clientProtocol(r) + + ", server protocol " + strconv.Itoa(membyProtocolVersion) + ". Update the app or server." + } + return true, "" +} + // handleUpdate answers the client's version check. // // Its own endpoint rather than a field on /v1/home: the home payload is cached per user, diff --git a/server/internal/cache/cache.go b/server/internal/cache/cache.go index 021a3e3..1ff546f 100644 --- a/server/internal/cache/cache.go +++ b/server/internal/cache/cache.go @@ -87,7 +87,7 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID, // Recommendations cost several Emby queries to build, so they must survive the cache // wipe that every favourite toggle triggers. Only a genuine change in viewing history // — a finished playback — retires them, via [Cache.InvalidateRecommendations]. -func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v2", userID) } +func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) } func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error { return c.Delete(ctx, RecommendationsKey(userID)) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 697e4a4..f326a4e 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -78,6 +78,26 @@ type Config struct { SonarrAPIKey string SonarrTTL time.Duration SonarrLocation *time.Location + + // SonarrAlertWindow is how long after an episode airs the "aired, coming soon" + // banner keeps being offered to clients. Zero turns the banners off without + // touching the airing-today row. + SonarrAlertWindow time.Duration + + // Tracearr is an optional, read-only source of completion, session-length and + // direct-play signals for the per-user For You area. The public API key stays in + // the gateway and is never returned to a TV. + TracearrURL string + TracearrAPIKey string + TracearrServerID string + // TracearrSyncInterval imports recent changed sessions. FullInterval reconciles + // late/out-of-order updates and deletions without needing a source cursor. + TracearrSyncInterval time.Duration + TracearrFullInterval time.Duration + // ForYouMinRebuildAge coalesces bursts of playback/library changes. RefreshInterval + // is the acceptable age of a prepared pool before it is refreshed. + ForYouMinRebuildAge time.Duration + ForYouRefreshInterval time.Duration } func Load() (Config, error) { @@ -104,16 +124,24 @@ func Load() (Config, error) { ReleasePublishToken: strings.TrimSpace( os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"), ), - SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour), - SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute), - SyncOnStart: boolean("MEMBY_SYNC_ON_START", false), - SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")), - SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")), - AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour), - UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second), - SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"), - SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")), - SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute), + SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour), + SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute), + SyncOnStart: boolean("MEMBY_SYNC_ON_START", false), + SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")), + SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")), + AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour), + UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second), + SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"), + SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")), + SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute), + SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour), + TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"), + TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")), + TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")), + TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute), + TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour), + ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 10*time.Minute), + ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 30*time.Minute), } if c.EmbyURL == "" { @@ -134,6 +162,9 @@ func Load() (Config, error) { if (c.SonarrURL == "") != (c.SonarrAPIKey == "") { return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together") } + if (c.TracearrURL == "") != (c.TracearrAPIKey == "") { + return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_API_KEY must be set together") + } location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland")) if err != nil { return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err) diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go index b6b219a..625ed8b 100644 --- a/server/internal/emby/client.go +++ b/server/internal/emby/client.go @@ -47,6 +47,14 @@ type AuthResult struct { ServerID string `json:"ServerId"` } +type User struct { + ID string `json:"Id"` + Name string `json:"Name"` + Policy struct { + IsDisabled bool `json:"IsDisabled"` + } `json:"Policy"` +} + // Summary is the minimal view of an item the gateway needs for its own logic. type Summary struct { ID string `json:"Id"` @@ -57,6 +65,35 @@ type Summary struct { } `json:"UserData"` } +type PlaybackInfo struct { + MediaSources []MediaSourceInfo `json:"MediaSources"` + PlaySessionID string `json:"PlaySessionId"` +} + +type MediaSourceInfo struct { + ID string `json:"Id"` + MediaStreams []MediaStream `json:"MediaStreams"` + DirectStreamURL string `json:"DirectStreamUrl"` + TranscodingURL string `json:"TranscodingUrl"` +} + +type MediaStream struct { + Index int `json:"Index"` + Type string `json:"Type"` + Codec string `json:"Codec"` + Title string `json:"Title"` + DisplayTitle string `json:"DisplayTitle"` + Language string `json:"Language"` + IsDefault bool `json:"IsDefault"` + IsForced bool `json:"IsForced"` + IsHearingImpaired bool `json:"IsHearingImpaired"` + IsExternal bool `json:"IsExternal"` + IsTextSubtitleStream bool `json:"IsTextSubtitleStream"` + SupportsExternalStream bool `json:"SupportsExternalStream"` + DeliveryURL string `json:"DeliveryUrl"` + DeliveryMethod string `json:"DeliveryMethod"` +} + // APIError carries an upstream Emby status code so handlers can mirror it. type APIError struct { StatusCode int @@ -115,6 +152,20 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error { return c.do(req, nil) } +// Users returns the household accounts visible to an administrative/service token. +// It is used only by the background For You builder, never on a television request. +func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) { + req, err := c.newRequest(ctx, http.MethodGet, "/Users", nil, cred, nil) + if err != nil { + return nil, err + } + var users []User + if err := c.do(req, &users); err != nil { + return nil, err + } + return users, nil +} + func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) { return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params) } @@ -167,6 +218,93 @@ func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields stri return raw, nil } +func (c *Client) PlaybackInfo( + ctx context.Context, + cred Credentials, + itemID string, + startTicks int64, + subtitleStreamIndex *int, + currentPlaySessionID string, +) (*PlaybackInfo, error) { + params := url.Values{ + "UserId": {cred.UserID}, + "IsPlayback": {"true"}, + } + body, err := json.Marshal(map[string]any{ + "Id": itemID, "UserId": cred.UserID, "IsPlayback": true, + "StartTimeTicks": startTicks, + "DeviceProfile": map[string]any{ + "Name": "Memby Android TV", "SupportedMediaTypes": "Video", + "DirectPlayProfiles": []map[string]string{ + { + "Container": "mkv,mp4,m4v,mov,webm,ts,mpegts,avi", + "VideoCodec": "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4", + "AudioCodec": "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm", + "Type": "Video", + }, + }, + "TranscodingProfiles": []map[string]string{ + { + "Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac", + "Protocol": "hls", "Type": "Video", "Context": "Streaming", + }, + }, + "SubtitleProfiles": []map[string]string{ + {"Format": "srt", "Method": "External"}, + {"Format": "subrip", "Method": "External"}, + {"Format": "ass", "Method": "External"}, + {"Format": "ssa", "Method": "External"}, + {"Format": "vtt", "Method": "External"}, + {"Format": "webvtt", "Method": "External"}, + {"Format": "mov_text", "Method": "External"}, + {"Format": "tx3g", "Method": "External"}, + {"Format": "pgs", "Method": "Encode"}, + {"Format": "pgssub", "Method": "Encode"}, + {"Format": "sup", "Method": "Encode"}, + {"Format": "vobsub", "Method": "Encode"}, + {"Format": "dvdsub", "Method": "Encode"}, + }, + }, + }) + var requestBody map[string]any + if err == nil { + err = json.Unmarshal(body, &requestBody) + } + if subtitleStreamIndex != nil { + requestBody["SubtitleStreamIndex"] = *subtitleStreamIndex + } + if currentPlaySessionID != "" { + requestBody["CurrentPlaySessionId"] = currentPlaySessionID + } + if err == nil { + body, err = json.Marshal(requestBody) + } + if err != nil { + return nil, err + } + req, err := c.newRequest( + ctx, + http.MethodPost, + "/Items/"+url.PathEscape(itemID)+"/PlaybackInfo", + params, + cred, + bytes.NewReader(body), + ) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + var out PlaybackInfo + if err := c.do(req, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) { + return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params) +} + // SetFavorite/SetPlayed return Emby's resulting UserData verbatim. func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) { method := http.MethodDelete @@ -187,7 +325,13 @@ func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, } // ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped". -func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error { +func (c *Client) ReportPlayback( + ctx context.Context, + cred Credentials, + phase, itemID, mediaSourceID, playSessionID, playMethod, eventName string, + positionTicks int64, + isPaused bool, +) error { var path string switch phase { case "started": @@ -202,12 +346,22 @@ func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase str body, err := json.Marshal(map[string]any{ "ItemId": itemID, + "MediaSourceId": mediaSourceID, + "PlaySessionId": playSessionID, "PositionTicks": positionTicks, "IsPaused": isPaused, "IsMuted": false, "CanSeek": true, - "PlayMethod": "DirectPlay", + "PlayMethod": playMethod, }) + if phase == "progress" { + var fields map[string]any + if err := json.Unmarshal(body, &fields); err != nil { + return err + } + fields["EventName"] = eventName + body, err = json.Marshal(fields) + } if err != nil { return err } @@ -249,6 +403,50 @@ func (c *Client) StreamURL(cred Credentials, itemID string) string { return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode()) } +// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL. +func (c *Client) DeliveryURL(cred Credentials, delivery string) string { + delivery = strings.TrimSpace(delivery) + if delivery == "" { + return "" + } + var resolved string + if parsed, err := url.Parse(delivery); err == nil && parsed.IsAbs() { + resolved = parsed.String() + } else { + resolved = c.publicURL + "/" + strings.TrimLeft(delivery, "/") + } + parsed, err := url.Parse(resolved) + if err != nil { + return resolved + } + query := parsed.Query() + if query.Get("api_key") == "" { + query.Set("api_key", cred.Token) + parsed.RawQuery = query.Encode() + } + return parsed.String() +} + +// SubtitleURL uses Emby's stable subtitle download route rather than the optional +// MediaStream.DeliveryUrl. VTT normalizes every text subtitle codec before it reaches +// the TV. +func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, index int, extensions ...string) string { + if mediaSourceID == "" { + mediaSourceID = itemID + } + extension := "vtt" + if len(extensions) > 0 && strings.TrimSpace(extensions[0]) != "" { + extension = extensions[0] + } + path := fmt.Sprintf( + "/Videos/%s/%s/Subtitles/%d/Stream.%s", + url.PathEscape(itemID), + url.PathEscape(mediaSourceID), + index, url.PathEscape(extension), + ) + return c.DeliveryURL(cred, path) +} + // Ping checks that Emby is reachable, for readiness probes. func (c *Client) Ping(ctx context.Context) error { req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil) diff --git a/server/internal/library/syncer.go b/server/internal/library/syncer.go index 2091258..acd8824 100644 --- a/server/internal/library/syncer.go +++ b/server/internal/library/syncer.go @@ -56,14 +56,22 @@ type Syncer struct { // is borrowed instead. serviceCred emby.Credentials - mu sync.Mutex - running bool + mu sync.Mutex + running bool + afterSync func() } func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer { return &Syncer{emby: embyClient, store: st, serviceCred: serviceCred, log: log} } +// SetAfterSync installs the inexpensive invalidation callback used by derived data. +func (s *Syncer) SetAfterSync(callback func()) { + s.mu.Lock() + defer s.mu.Unlock() + s.afterSync = callback +} + // Result summarises one import. type Result struct { Kind string `json:"kind"` @@ -150,6 +158,12 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error) "kind", kind, "trigger", trigger, "seen", result.Seen, "upserted", result.Upserted, "removed", result.Removed, "duration", result.Duration.Round(time.Millisecond)) + s.mu.Lock() + afterSync := s.afterSync + s.mu.Unlock() + if afterSync != nil { + afterSync() + } return result, nil } @@ -345,5 +359,12 @@ func searchText(parsed syncItem) string { parts = append(parts, strconv.Itoa(*parsed.ProductionYear)) } parts = append(parts, parsed.Genres...) + // Studios too, so "A24" or "Pixar" finds a shelf's worth of titles. Existing rows + // keep their old text until the next full import rewrites them. + for _, studio := range parsed.Studios { + if studio.Name != "" { + parts = append(parts, studio.Name) + } + } return strings.Join(parts, " ") } diff --git a/server/internal/library/syncer_test.go b/server/internal/library/syncer_test.go index 609486d..68b8b9a 100644 --- a/server/internal/library/syncer_test.go +++ b/server/internal/library/syncer_test.go @@ -70,6 +70,23 @@ func TestSearchTextIncludesSeriesNameSoEpisodesAreFindable(t *testing.T) { } } +func TestSearchTextIncludesStudiosSoAStudioNameFindsItsTitles(t *testing.T) { + text := searchText(syncItem{ + Name: "Everything Everywhere All at Once", + Type: "Movie", + Studios: []struct { + Name string `json:"Name"` + }{{Name: "A24"}, {Name: ""}}, + }) + + if !strings.Contains(text, "A24") { + t.Fatalf("search text %q is missing the studio", text) + } + if strings.Contains(text, " ") { + t.Fatalf("an unnamed studio should be skipped, not joined as a gap: %q", text) + } +} + func TestSearchTextDoesNotRepeatTheTitleForAMovie(t *testing.T) { text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"}) diff --git a/server/internal/recommend/engine.go b/server/internal/recommend/engine.go index 04b6586..dd4685a 100644 --- a/server/internal/recommend/engine.go +++ b/server/internal/recommend/engine.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "log/slog" + "math" "net/url" "sort" "strconv" "strings" "sync" + "time" "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/tracearr" ) // Row is one horizontal strip on the TV home screen. @@ -28,6 +31,13 @@ type Source interface { Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error) } +// NextUpSource is optional because the on-demand engine and small test sources do not +// need it. The production Emby client implements it; prepared rebuilds use one Next Up +// request to prove that an abandoned programme still has an unwatched episode. +type NextUpSource interface { + NextUp(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error) +} + // LibrarySource is the imported catalogue. When present, the candidate pool comes from // Postgres instead of Emby, which takes the rebuild off Emby entirely. type LibrarySource interface { @@ -44,15 +54,29 @@ type CuratedLibrarySource interface { ) ([]json.RawMessage, error) } +type TracearrSource interface { + History(ctx context.Context, username string, limit int) ([]tracearr.Session, error) +} + +type BehaviorSource interface { + BrowsingCandidates( + ctx context.Context, + userID string, + since time.Time, + limit int, + ) ([]json.RawMessage, error) +} + // CuratedRow defines one reusable server-side shelf. Filtering determines membership; // the user's profile determines both item order and shelf order. type CuratedRow struct { - ID string - Title string - Kind string - ItemTypes []string - Genres []string - Studios []string + ID string + Title string + Kind string + ItemTypes []string + Genres []string + Studios []string + RequireAffinity bool } type Engine struct { @@ -60,7 +84,9 @@ type Engine struct { log *slog.Logger // Library is optional; nil (or an empty library) falls back to querying Emby. - Library LibrarySource + Library LibrarySource + Tracearr TracearrSource + Behavior BehaviorSource // MinRowItems is the shortest row worth showing. A two-item "Recommended" strip // looks broken next to full rows, so short rows are dropped entirely. @@ -72,6 +98,333 @@ type Engine struct { CuratedRows []CuratedRow } +// ForYouOptions are request-scoped constraints chosen on the television. +type ForYouOptions struct { + // AvailableMinutes is zero for no time limit. + AvailableMinutes int +} + +type compatibilityProfile struct { + directCodecs map[string]int + transcodeCodecs map[string]int +} + +// BuildForYou creates the dedicated, explainable recommendation area. Emby supplies +// catalogue metadata, Tracearr supplies completion and real device/playback outcomes, +// and Memby's own row analytics supplies browsing intent. +func (e *Engine) BuildForYou( + ctx context.Context, + cred emby.Credentials, + username string, + options ForYouOptions, +) ([]Row, error) { + history, favorites, err := e.gatherSignals(ctx, cred) + if err != nil { + return nil, err + } + profile := BuildProfile(history, favorites) + + var sessions []tracearr.Session + if e.Tracearr != nil { + if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil { + e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr) + } else { + sessions = recommendationSessions(fetched) + e.applyTracearrSignals(&profile, history, sessions) + } + } + + browsed := map[string]bool{} + if e.Behavior != nil { + raws, browseErr := e.Behavior.BrowsingCandidates( + ctx, + cred.UserID, + time.Now().Add(-30*24*time.Hour), + 30, + ) + if browseErr != nil { + e.log.Warn("browsing signals unavailable", "error", browseErr) + } else { + for i, item := range Decode(raws) { + weight := 0.55 * powDecay(0.92, i) + profile.absorbTaste(item, weight) + browsed[item.ID] = true + } + } + } + + genres := profile.TopGenres(4) + if len(genres) == 0 { + return []Row{}, nil + } + candidates, ok := e.libraryCandidatesForYou(ctx, cred, genres) + if !ok { + return []Row{}, nil + } + + compatibility := buildCompatibilityProfile(sessions) + items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize) + if len(items) < e.MinRowItems { + return []Row{}, nil + } + + raws := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + reason, compatibilityLabel := explainRecommendation( + profile, + item, + options.AvailableMinutes, + compatibility, + browsed[item.ID], + ) + raws = append(raws, enrichRecommendation(item.Raw, reason, compatibilityLabel)) + } + title := "Top picks for you" + if options.AvailableMinutes > 0 { + title = "Top picks that fit in " + strconv.Itoa(options.AvailableMinutes) + " minutes" + } + return []Row{{ + ID: "for-you:picks", + Title: title, + Kind: "for-you", + Items: raws, + }}, nil +} + +func powDecay(base float64, position int) float64 { + return math.Pow(base, float64(position)) +} + +func (e *Engine) applyTracearrSignals( + profile *Profile, + history []Item, + sessions []tracearr.Session, +) { + byTitle := make(map[string]Item, len(history)) + for _, item := range history { + byTitle[item.TitleKey()] = item + } + for i, session := range sessions { + if session.Completion() > 0 { + profile.SeenTitles[tracearrSeenKey(session)] = true + } + item, ok := byTitle[session.TitleKey()] + if !ok { + continue + } + // Completion distinguishes "finished it twice" from "abandoned after ten + // minutes"; recency lets changing tastes move promptly. + weight := (0.2 + session.Completion()) * powDecay(0.985, i) + profile.absorbTaste(item, weight) + } +} + +func (e *Engine) libraryCandidatesForYou( + ctx context.Context, + cred emby.Credentials, + genres []string, +) ([]Item, bool) { + if e.Library != nil { + raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*12) + if err == nil && len(raws) > 0 { + return Decode(raws), true + } + if err != nil { + e.log.Warn("for-you library candidates failed; falling back to emby", "error", err) + } + } + result, err := e.source.Items(ctx, cred, url.Values{ + "IncludeItemTypes": {"Movie,Series"}, + "Recursive": {"true"}, + "Filters": {"IsUnplayed"}, + "Genres": {strings.Join(genres, "|")}, + "SortBy": {"CommunityRating"}, + "SortOrder": {"Descending"}, + "Limit": {strconv.Itoa(e.RowSize * 8)}, + "Fields": {candidateFields + ",MediaStreams,Container"}, + "ImageTypeLimit": {"1"}, + "EnableImages": {"true"}, + "EnableImageTypes": {rowImageTypes}, + "EnableUserData": {"true"}, + }) + if err != nil { + e.log.Warn("for-you emby candidates failed", "error", err) + return nil, false + } + return Decode(result.Items), len(result.Items) > 0 +} + +func buildCompatibilityProfile(sessions []tracearr.Session) compatibilityProfile { + profile := compatibilityProfile{ + directCodecs: map[string]int{}, + transcodeCodecs: map[string]int{}, + } + for _, session := range sessions { + if !session.IsTelevisionSession() { + continue + } + for _, codec := range []string{session.SourceVideoCodec, session.SourceAudioCodec} { + codec = strings.ToLower(strings.TrimSpace(codec)) + if codec == "" { + continue + } + if session.IsTranscode || + strings.EqualFold(session.VideoDecision, "transcode") || + strings.EqualFold(session.AudioDecision, "transcode") { + profile.transcodeCodecs[codec]++ + } else { + profile.directCodecs[codec]++ + } + } + } + return profile +} + +func compatibilityScore(item Item, profile compatibilityProfile) float64 { + var score float64 + var known int + for _, stream := range item.MediaStreams { + if !strings.EqualFold(stream.Type, "Video") && !strings.EqualFold(stream.Type, "Audio") { + continue + } + codec := strings.ToLower(strings.TrimSpace(stream.Codec)) + direct, transcode := profile.directCodecs[codec], profile.transcodeCodecs[codec] + if direct+transcode == 0 { + continue + } + known++ + score += float64(direct-transcode) / float64(direct+transcode) + } + if known == 0 { + return 0 + } + return score / float64(known) +} + +func rankForYou( + profile Profile, + candidates []Item, + availableMinutes int, + compatibility compatibilityProfile, + limit int, +) []Item { + type scored struct { + item Item + score float64 + } + ranked := make([]scored, 0, len(candidates)) + seen := map[string]bool{} + for _, candidate := range candidates { + if seen[candidate.ID] { + continue + } + seen[candidate.ID] = true + base := profile.Score(candidate) + if base < 0 { + continue + } + runtime := candidate.RuntimeMinutes() + if availableMinutes > 0 && (runtime <= 0 || runtime > availableMinutes) { + continue + } + score := base + compatibilityScore(candidate, compatibility)*1.4 + if availableMinutes > 0 { + // Prefer a satisfying fit over something dramatically shorter, without + // allowing runtime to overwhelm taste. + score += float64(runtime) / float64(availableMinutes) * 0.35 + } + ranked = append(ranked, scored{item: candidate, score: score}) + } + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].score != ranked[j].score { + return ranked[i].score > ranked[j].score + } + return ranked[i].item.Name < ranked[j].item.Name + }) + if limit > 0 && len(ranked) > limit { + ranked = ranked[:limit] + } + out := make([]Item, 0, len(ranked)) + for _, entry := range ranked { + out = append(out, entry.item) + } + return out +} + +func explainRecommendation( + profile Profile, + item Item, + availableMinutes int, + compatibility compatibilityProfile, + browsed bool, +) (string, string) { + top := profile.TopGenres(5) + matched := "" + for _, wanted := range top { + for _, genre := range item.Genres { + if strings.EqualFold(wanted, genre) { + matched = genre + break + } + } + if matched != "" { + break + } + } + reasons := make([]string, 0, 3) + if browsed { + reasons = append(reasons, "You explored this recently") + } else if matched != "" { + reasons = append(reasons, "Matches your "+matched+" viewing") + } else if len(profile.Seeds) > 0 { + reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name) + } else { + reasons = append(reasons, "Matches your recent viewing") + } + if availableMinutes > 0 && item.RuntimeMinutes() > 0 { + reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window") + } + compatibilityLabel := "" + switch score := compatibilityScore(item, compatibility); { + case score > 0.2: + compatibilityLabel = "Direct plays well on this TV" + reasons = append(reasons, compatibilityLabel) + case score < -0.2: + compatibilityLabel = "May need transcoding on this TV" + default: + compatibilityLabel = "TV compatibility not yet learned" + } + return strings.Join(reasons, " · "), compatibilityLabel +} + +func enrichRecommendation(raw json.RawMessage, reason, compatibility string) json.RawMessage { + var item map[string]any + if err := json.Unmarshal(raw, &item); err != nil { + return raw + } + item["MembyRecommendationReason"] = reason + item["MembyCompatibility"] = compatibility + enriched, err := json.Marshal(item) + if err != nil { + return raw + } + return enriched +} + +// EnrichPreparedRecommendation adds request-time details to a candidate selected from +// the prepared PostgreSQL pool. The stored reason remains stable; only the viewer's +// current time budget is appended here. +func EnrichPreparedRecommendation( + raw json.RawMessage, + reason, compatibility string, + availableMinutes int, +) json.RawMessage { + if availableMinutes > 0 { + reason += " · fits your " + strconv.Itoa(availableMinutes) + "-minute window" + } + return enrichRecommendation(raw, reason, compatibility) +} + func NewEngine(source Source, log *slog.Logger) *Engine { return &Engine{ source: source, @@ -89,22 +442,81 @@ func NewEngine(source Source, log *slog.Logger) *Engine { }, { ID: "curated:drama-shows", - Title: "Drama TV Shows", + Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}, }, { ID: "curated:comedy-shows", - Title: "Comedy TV Shows", + Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}, }, + { + ID: "curated:horror-shows", + Title: "Horror Shows", + Kind: "shows", + ItemTypes: []string{"Series"}, + Genres: []string{"Horror"}, + }, + // Movie shelves are deliberately numerous definitions but sparse output: + // only the user's six strongest matching genres and three strongest studio + // families survive buildCuratedRows. + movieGenreRow("action", "Action"), + movieGenreRow("adventure", "Adventure"), + movieGenreRow("animation", "Animation"), + movieGenreRow("comedy", "Comedy"), + movieGenreRow("crime", "Crime"), + movieGenreRow("documentary", "Documentary"), + movieGenreRow("drama", "Drama"), + movieGenreRow("family", "Family"), + movieGenreRow("fantasy", "Fantasy"), + movieGenreRow("horror", "Horror"), + movieGenreRow("mystery", "Mystery"), + movieGenreRow("romance", "Romance"), + movieGenreRow("science-fiction", "Science Fiction"), + movieGenreRow("thriller", "Thriller"), + movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"), + movieStudioRow( + "disney", "Disney", + "Disney", "Walt Disney Pictures", "Walt Disney Animation Studios", + ), + movieStudioRow("marvel", "Marvel Studios", "Marvel Studios"), + movieStudioRow("lucasfilm", "Lucasfilm", "Lucasfilm", "Lucasfilm Ltd."), + movieStudioRow( + "dreamworks", "DreamWorks", + "DreamWorks", "DreamWorks Pictures", "DreamWorks Animation", + ), + movieStudioRow( + "warner-bros", "Warner Bros.", + "Warner Bros.", "Warner Bros. Pictures", "Warner Brothers", + ), + movieStudioRow( + "universal", "Universal", + "Universal Pictures", "Universal Studios", + ), + movieStudioRow("a24", "A24", "A24"), + movieStudioRow("studio-ghibli", "Studio Ghibli", "Studio Ghibli"), }, } } +func movieGenreRow(id, genre string) CuratedRow { + return CuratedRow{ + ID: "curated:movies:genre:" + id, Title: genre + " Movies", Kind: "movies", + ItemTypes: []string{"Movie"}, Genres: []string{genre}, RequireAffinity: true, + } +} + +func movieStudioRow(id, title string, studios ...string) CuratedRow { + return CuratedRow{ + ID: "curated:movies:studio:" + id, Title: "More from " + title, Kind: "movies", + ItemTypes: []string{"Movie"}, Studios: studios, RequireAffinity: true, + } +} + const ( historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks" candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio" @@ -152,11 +564,22 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row { } definitions := make([]rankedDefinition, 0, len(e.CuratedRows)) for order, definition := range e.CuratedRows { - definitions = append(definitions, rankedDefinition{ + affinity := profile.CollectionAffinity(definition.Genres, definition.Studios) + // Studio signals are intentionally damped while scoring individual titles. + // Restore enough weight at shelf level for a genuinely followed studio to earn + // a row before genre shelves consume all of its films during deduplication. + if strings.HasPrefix(definition.ID, "curated:movies:studio:") { + affinity *= 3 + } + ranked := rankedDefinition{ definition: definition, - affinity: profile.CollectionAffinity(definition.Genres, definition.Studios), + affinity: affinity, order: order, - }) + } + if definition.RequireAffinity && ranked.affinity <= 0 { + continue + } + definitions = append(definitions, ranked) } sort.SliceStable(definitions, func(i, j int) bool { if definitions[i].affinity != definitions[j].affinity { @@ -166,8 +589,23 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row { }) rows := make([]Row, 0, len(definitions)) + // A series may carry several genres. Give it to the user's highest-affinity shelf + // only, so scrolling Shows never reveals the same card again under another label. + seenItems := make(map[string]struct{}) + movieGenreRows := 0 + movieStudioRows := 0 for _, ranked := range definitions { definition := ranked.definition + switch { + case strings.HasPrefix(definition.ID, "curated:movies:genre:"): + if movieGenreRows >= 6 { + continue + } + case strings.HasPrefix(definition.ID, "curated:movies:studio:"): + if movieStudioRows >= 3 { + continue + } + } raws, err := library.CuratedCandidates( ctx, definition.ItemTypes, @@ -179,16 +617,35 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row { e.log.Warn("curated row failed", "row", definition.ID, "error", err) continue } - items := RankCollection(profile, Decode(raws), e.RowSize) + rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2) + items := make([]Item, 0, e.RowSize) + for _, item := range rankedItems { + if _, seen := seenItems[item.ID]; seen { + continue + } + items = append(items, item) + if len(items) == e.RowSize { + break + } + } if len(items) < e.MinRowItems { continue } + for _, item := range items { + seenItems[item.ID] = struct{}{} + } rows = append(rows, Row{ ID: definition.ID, Title: definition.Title, Kind: definition.Kind, Items: Raws(items), }) + if strings.HasPrefix(definition.ID, "curated:movies:genre:") { + movieGenreRows++ + } + if strings.HasPrefix(definition.ID, "curated:movies:studio:") { + movieStudioRows++ + } } return rows } @@ -227,18 +684,20 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist "Recursive": {"true"}, "SortBy": {"DatePlayed"}, "SortOrder": {"Descending"}, - "Limit": {"20"}, - "Fields": {historyFields}, - "EnableUserData": {"true"}, - "EnableImages": {"false"}, + // Imported catalogue rows have no user state, so this query is also the + // exclusion set. Household libraries are small enough to fetch it completely. + "Limit": {"5000"}, + "Fields": {historyFields}, + "EnableUserData": {"true"}, + "EnableImages": {"false"}, }) fetch(&played, url.Values{ "Filters": {"IsPlayed"}, - "IncludeItemTypes": {"Movie,Episode"}, + "IncludeItemTypes": {"Movie,Episode,Series"}, "Recursive": {"true"}, "SortBy": {"DatePlayed"}, "SortOrder": {"Descending"}, - "Limit": {"60"}, + "Limit": {"5000"}, "Fields": {historyFields}, "EnableUserData": {"true"}, "EnableImages": {"false"}, @@ -261,6 +720,28 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist return append(resumable, played...), favorites, nil } +func recommendationSessions(sessions []tracearr.Session) []tracearr.Session { + out := make([]tracearr.Session, 0, len(sessions)) + for _, session := range sessions { + // Prerolls are delivery mechanics, not a viewer choice. Treating dozens of + // completed prerolls as taste evidence overwhelms real household history. + if strings.HasPrefix(session.TitleKey(), "preroll") { + continue + } + out = append(out, session) + } + return out +} + +func tracearrSeenKey(session tracearr.Session) string { + key := session.TitleKey() + if key != "" && strings.EqualFold(session.MediaType, "movie") && + session.Year != nil && *session.Year > 0 { + return key + "|" + strconv.Itoa(*session.Year) + } + return key +} + // libraryCandidates reads the pool from the imported library. Returns ok=false when // there is no library, it is empty, or it errors — every one of which means "ask Emby". func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) { diff --git a/server/internal/recommend/engine_test.go b/server/internal/recommend/engine_test.go index d249b19..279e294 100644 --- a/server/internal/recommend/engine_test.go +++ b/server/internal/recommend/engine_test.go @@ -7,11 +7,14 @@ import ( "io" "log/slog" "net/url" + "strconv" "strings" "sync" "testing" + "time" "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/tracearr" ) // fakeSource records the queries the engine makes and replays canned answers. @@ -22,6 +25,8 @@ type fakeSource struct { similar map[string][]json.RawMessage itemsErr error similarErr error + nextUp []json.RawMessage + nextUpErr error genreQueries []string similarSeeds []string @@ -31,6 +36,36 @@ type fakeCuratedLibrary struct { byGenre map[string][]json.RawMessage } +type fakeForYouLibrary struct { + items []json.RawMessage +} + +func (f *fakeForYouLibrary) AllRecommendationCandidates( + _ context.Context, +) ([]json.RawMessage, error) { + return f.items, nil +} + +func (f *fakeForYouLibrary) LibraryCandidates( + _ context.Context, + _ []string, + _ int, +) ([]json.RawMessage, error) { + return f.items, nil +} + +type fakeTracearr struct { + sessions []tracearr.Session +} + +func (f fakeTracearr) History( + _ context.Context, + _ string, + _ int, +) ([]tracearr.Session, error) { + return f.sessions, nil +} + func (f *fakeCuratedLibrary) LibraryCandidates( _ context.Context, _ []string, @@ -62,7 +97,11 @@ func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Val f.genreQueries = append(f.genreQueries, genres) } key := params.Get("Filters") - return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil + items := f.itemsByFilter[key] + if limit, err := strconv.Atoi(params.Get("Limit")); err == nil && limit > 0 && len(items) > limit { + items = items[:limit] + } + return &emby.ItemsResult{Items: items}, nil } func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) { @@ -75,6 +114,69 @@ func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID strin return &emby.ItemsResult{Items: f.similar[itemID]}, nil } +func (f *fakeSource) NextUp( + _ context.Context, + _ emby.Credentials, + _ url.Values, +) (*emby.ItemsResult, error) { + if f.nextUpErr != nil { + return nil, f.nextUpErr + } + return &emby.ItemsResult{Items: f.nextUp}, nil +} + +func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) { + now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) + catalogue := Decode([]json.RawMessage{ + json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`), + json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`), + json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`), + json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`), + }) + session := func(show string, season, episode, daysAgo int) tracearr.Session { + value := tracearr.Session{ + ID: "session-" + show, MediaType: "episode", ShowTitle: show, + SeasonNumber: intPointer(season), EpisodeNumber: intPointer(episode), + Watched: true, StoppedAt: now.AddDate(0, 0, -daysAgo).Format(time.RFC3339Nano), + } + return value + } + sessions := []tracearr.Session{ + session("Early Show", 1, 2, 40), + session("Deep Show", 2, 8, 60), + session("Complete Show", 4, 10, 50), + session("Recent Show", 1, 3, 5), + } + nextUp := Decode([]json.RawMessage{ + json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`), + json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`), + json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`), + }) + + got := abandonedShowCandidates( + newCatalogueIndex(catalogue), + sessions, + nextUp, + compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}}, + now, + ) + if len(got) != 2 { + t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got) + } + if got[0].ItemID != "deep" || got[1].ItemID != "early" { + t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID) + } + if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" { + t.Fatalf("later-season reason = %q", got[0].RecommendationReason) + } + if got[1].RecommendationReason != "You left this in season 1 · pick it up again" { + t.Fatalf("season-one reason = %q", got[1].RecommendationReason) + } + if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 { + t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes) + } +} + func raw(id, name, itemType string, genres ...string) json.RawMessage { quoted := make([]string, 0, len(genres)) for _, g := range genres { @@ -128,6 +230,117 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) { } } +func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) { + source := &fakeSource{ + itemsByFilter: map[string][]json.RawMessage{ + "IsResumable": {}, + "IsPlayed": { + json.RawMessage(`{"Id":"seen","Name":"Arrival","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":69600000000}`), + }, + "IsFavorite": {}, + }, + } + engine := testEngine(source) + engine.Library = &fakeForYouLibrary{items: []json.RawMessage{ + json.RawMessage(`{"Id":"short","Name":"Moon","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000,"MediaStreams":[{"Type":"Video","Codec":"h264"}]}`), + json.RawMessage(`{"Id":"long","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":93000000000,"MediaStreams":[{"Type":"Video","Codec":"hevc"}]}`), + }} + session := tracearr.Session{ + MediaType: "movie", + MediaTitle: "Arrival", + Watched: true, + Platform: "Android TV", + SourceVideoCodec: "h264", + VideoDecision: "directplay", + } + engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}} + engine.MinRowItems = 1 + + rows, err := engine.BuildForYou( + context.Background(), + emby.Credentials{UserID: "u1"}, + "Matt", + ForYouOptions{AvailableMinutes: 100}, + ) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || len(rows[0].Items) != 1 { + t.Fatalf("rows = %+v", rows) + } + if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason"`) || + !strings.Contains(string(rows[0].Items[0]), `100-minute window`) { + t.Fatalf("explanation missing: %s", rows[0].Items[0]) + } + if strings.Contains(string(rows[0].Items[0]), `"Id":"long"`) { + t.Fatalf("over-budget item was retained: %s", rows[0].Items[0]) + } +} + +func TestPrepareForYouKeepsAnOverProvisionedPoolAndSpecificEvidence(t *testing.T) { + source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{ + "IsResumable": {}, + "IsPlayed": { + json.RawMessage(`{"Id":"watched","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"UserData":{"Played":true}}`), + }, + "IsFavorite": {}, + }} + catalogue := []json.RawMessage{ + json.RawMessage(`{"Id":"arrival","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"RunTimeTicks":69600000000}`), + } + for i := 0; i < 30; i++ { + catalogue = append(catalogue, json.RawMessage( + `{"Id":"candidate-`+strconv.Itoa(i)+`","Name":"Candidate `+strconv.Itoa(i)+ + `","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000}`, + )) + } + engine := testEngine(source) + engine.Library = &fakeForYouLibrary{items: catalogue} + session := tracearr.Session{ + ID: "trace-1", ServerID: "server-1", MediaTitle: "Arrival", + MediaType: "movie", Year: intPointer(2016), Watched: true, + StartedAt: "2026-07-20T08:00:00Z", + } + session.User.ID = "trace-user" + session.User.Username = "Matt" + + result, err := engine.PrepareForYou( + context.Background(), emby.Credentials{UserID: "emby-user"}, "Matt", + []tracearr.Session{session}, + ) + if err != nil { + t.Fatalf("PrepareForYou: %v", err) + } + if len(result.Candidates) < 30 { + t.Fatalf("prepared pool was prematurely row-sized: %d candidates", len(result.Candidates)) + } + foundSpecific := false + for _, candidate := range result.Candidates { + if strings.Contains(candidate.RecommendationReason, "Because you finished Arrival") { + foundSpecific = true + break + } + } + if !foundSpecific { + t.Fatal("expected a candidate explanation grounded in the completed Tracearr title") + } + if result.Profile.TracearrUserID != "trace-user" || len(result.Mappings) != 1 { + t.Fatalf("profile/mapping = %+v / %+v", result.Profile, result.Mappings) + } +} + +func intPointer(value int) *int { return &value } + +func TestStableEvidenceIndexDistributesCandidatesAcrossCompletedTitles(t *testing.T) { + seen := map[int]bool{} + for i := 0; i < 20; i++ { + seen[stableEvidenceIndex("candidate-"+strconv.Itoa(i), 3)] = true + } + if len(seen) != 3 { + t.Fatalf("evidence indices = %+v, want all three sources represented", seen) + } +} + func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) { source := &fakeSource{ itemsByFilter: map[string][]json.RawMessage{ @@ -179,6 +392,123 @@ func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) { } } +func TestBuildRowsExcludesPlayedTitlesBeyondTheOldHistoryWindow(t *testing.T) { + played := make([]json.RawMessage, 0, 61) + for i := 0; i < 60; i++ { + played = append(played, raw("history-"+strconv.Itoa(i), "History", "Movie", "Drama")) + } + played = append(played, raw("old-watched", "Old Watched", "Movie", "Drama")) + source := &fakeSource{ + itemsByFilter: map[string][]json.RawMessage{"IsPlayed": played}, + similar: map[string][]json.RawMessage{}, + } + engine := testEngine(source) + engine.Library = &fakeForYouLibrary{items: []json.RawMessage{ + raw("old-watched", "Old Watched", "Movie", "Drama"), + raw("new-pick", "New Pick", "Movie", "Drama"), + }} + engine.MinRowItems = 1 + + rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) + if err != nil { + t.Fatalf("BuildRows: %v", err) + } + for _, row := range rows { + for _, candidate := range row.Items { + if strings.Contains(string(candidate), `"Id":"old-watched"`) { + t.Fatalf("row %q retained a title older than the previous 60-item exclusion window", row.ID) + } + } + } +} + +func TestBuildForYouExcludesTracearrCompletedTitleMissingFromEmbyHistory(t *testing.T) { + source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{ + "IsPlayed": {raw("taste", "Taste", "Movie", "Science Fiction")}, + }} + engine := testEngine(source) + engine.Library = &fakeForYouLibrary{items: []json.RawMessage{ + json.RawMessage(`{"Id":"watched-copy","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"]}`), + raw("unseen", "Moon", "Movie", "Science Fiction"), + }} + year := 2016 + session := tracearr.Session{ + MediaType: "movie", MediaTitle: "Arrival", Year: &year, Watched: true, + } + engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}} + engine.MinRowItems = 1 + + rows, err := engine.BuildForYou( + context.Background(), emby.Credentials{UserID: "u1"}, "FamilyTV", ForYouOptions{}, + ) + if err != nil { + t.Fatalf("BuildForYou: %v", err) + } + if len(rows) != 1 || len(rows[0].Items) != 1 || + !strings.Contains(string(rows[0].Items[0]), `"Id":"unseen"`) { + t.Fatalf("Tracearr-completed title was not excluded: %+v", rows) + } +} + +func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) { + profile := Profile{ + GenreWeights: map[string]float64{"Drama": 2, "Science Fiction": 1}, + Seen: map[string]bool{}, SeenTitles: map[string]bool{}, + } + evidence := map[string][]PreparedEvidence{ + "drama": {{ + ItemID: "arrival", Title: "Arrival", + Genres: []string{"Drama", "Science Fiction"}, + }}, + } + counts := map[string]int{} + kinds := map[string]int{} + for i := 0; i < 20; i++ { + candidate := item( + "candidate-"+strconv.Itoa(i), "Candidate", "Movie", + []string{"Drama", "Science Fiction"}, 7, + ) + _, _, kind, _, _ := explainPreparedRecommendation( + profile, candidate, compatibilityProfile{}, false, evidence, counts, + ) + kinds[kind]++ + } + if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 { + t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"]) + } + if kinds["genre"] == 0 { + t.Fatalf("reason kinds were not mixed: %+v", kinds) + } +} + +func TestPreparedExplanationRejectsOneBroadGenreAsSpecificEvidence(t *testing.T) { + profile := Profile{ + GenreWeights: map[string]float64{"Drama": 2}, + Seen: map[string]bool{}, SeenTitles: map[string]bool{}, + } + evidence := map[string][]PreparedEvidence{ + "drama": {{ItemID: "source", Title: "Source", Genres: []string{"Drama"}}}, + } + _, _, kind, _, _ := explainPreparedRecommendation( + profile, item("candidate", "Candidate", "Movie", []string{"Drama"}, 7), + compatibilityProfile{}, false, evidence, map[string]int{}, + ) + if kind != "genre" { + t.Fatalf("one broad shared genre produced %q, want genre", kind) + } +} + +func TestRecommendationSessionsDiscardPrerolls(t *testing.T) { + sessions := []tracearr.Session{ + {MediaTitle: "PreRoll_Swirls"}, + {MediaTitle: "A Real Film"}, + } + got := recommendationSessions(sessions) + if len(got) != 1 || got[0].MediaTitle != "A Real Film" { + t.Fatalf("recommendation sessions = %+v", got) + } +} + func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) { source := &fakeSource{ itemsByFilter: map[string][]json.RawMessage{ @@ -256,6 +586,130 @@ func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) { } } +func TestDefaultCuratedRowsIncludePersonalizedShowGenres(t *testing.T) { + engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + got := map[string]string{} + for _, row := range engine.CuratedRows { + got[row.ID] = row.Title + } + + for id, title := range map[string]string{ + "curated:comedy-shows": "Comedy Shows", + "curated:drama-shows": "Drama Shows", + "curated:horror-shows": "Horror Shows", + } { + if got[id] != title { + t.Fatalf("%s title = %q, want %q", id, got[id], title) + } + } +} + +func TestDefaultCuratedRowsIncludeMovieGenresAndStudioFamilies(t *testing.T) { + engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + got := map[string]CuratedRow{} + for _, row := range engine.CuratedRows { + got[row.ID] = row + } + + for _, id := range []string{ + "curated:movies:genre:science-fiction", + "curated:movies:genre:animation", + "curated:movies:studio:pixar", + "curated:movies:studio:disney", + } { + row, ok := got[id] + if !ok { + t.Fatalf("missing default movie shelf %q", id) + } + if row.Kind != "movies" || !row.RequireAffinity { + t.Fatalf("movie shelf %q = %+v", id, row) + } + } +} + +func TestMovieShelvesRequireAffinityAndRankAStudioBeforeItsGenre(t *testing.T) { + source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{ + "IsPlayed": { + json.RawMessage(`{"Id":"watched","Name":"Toy Story","Type":"Movie","Genres":["Comedy"],"Studios":[{"Name":"Pixar Animation Studios"}]}`), + }, + }} + engine := testEngine(source) + engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{ + "Comedy": { + raw("comedy-1", "Comedy One", "Movie", "Comedy"), + raw("comedy-2", "Comedy Two", "Movie", "Comedy"), + }, + "Drama": { + raw("drama-1", "Drama One", "Movie", "Drama"), + raw("drama-2", "Drama Two", "Movie", "Drama"), + }, + "studio:Pixar": { + raw("pixar-1", "Pixar One", "Movie", "Animation"), + raw("pixar-2", "Pixar Two", "Movie", "Animation"), + }, + }} + engine.CuratedRows = []CuratedRow{ + movieGenreRow("comedy", "Comedy"), + movieGenreRow("drama", "Drama"), + movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"), + } + + rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) + if err != nil { + t.Fatal(err) + } + curated := make([]Row, 0, len(rows)) + for _, row := range rows { + if strings.HasPrefix(row.ID, "curated:movies:") { + curated = append(curated, row) + } + } + if len(curated) != 2 { + t.Fatalf("movie shelves = %+v, want Pixar and Comedy only", rowTitles(curated)) + } + if curated[0].ID != "curated:movies:studio:pixar" || + curated[1].ID != "curated:movies:genre:comedy" { + t.Fatalf("movie shelf order = %+v", rowTitles(curated)) + } +} + +func TestCuratedRowsDoNotRepeatCardsAcrossGenres(t *testing.T) { + source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{ + "IsPlayed": {raw("history", "Funny", "Episode", "Comedy")}, + }} + engine := testEngine(source) + engine.MinRowItems = 1 + engine.RowSize = 3 + engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{ + "Comedy": { + raw("shared", "Shared Show", "Series", "Comedy", "Drama"), + raw("comedy", "Comedy Only", "Series", "Comedy"), + }, + "Drama": { + raw("shared", "Shared Show", "Series", "Comedy", "Drama"), + raw("drama", "Drama Only", "Series", "Drama"), + }, + }} + engine.CuratedRows = []CuratedRow{ + {ID: "comedy", Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}}, + {ID: "drama", Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}}, + } + + rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) + if err != nil { + t.Fatal(err) + } + ids := map[string]int{} + for _, row := range rows { + for _, item := range Decode(row.Items) { + ids[item.ID]++ + } + } + if ids["shared"] != 1 { + t.Fatalf("shared card appeared %d times across curated rows", ids["shared"]) + } +} + func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) { source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}} engine := testEngine(source) diff --git a/server/internal/recommend/profile.go b/server/internal/recommend/profile.go index d4a680c..f1821d2 100644 --- a/server/internal/recommend/profile.go +++ b/server/internal/recommend/profile.go @@ -10,7 +10,9 @@ import ( "encoding/json" "math" "sort" + "strconv" "strings" + "unicode" ) // recencyDecay is applied per position down the history list. At 0.94, the 12th item @@ -26,14 +28,23 @@ const favoriteWeight = 0.6 // Item is the slice of an Emby item this package reasons about. The raw payload rides // along so rows can be emitted without re-fetching or re-encoding. type Item struct { - ID string `json:"Id"` - Name string `json:"Name"` - Type string `json:"Type"` - SeriesID string `json:"SeriesId"` - SeriesName string `json:"SeriesName"` - Genres []string `json:"Genres"` - CommunityRating float64 `json:"CommunityRating"` - Studios []struct { + ID string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + SeriesID string `json:"SeriesId"` + SeriesName string `json:"SeriesName"` + ProductionYear int `json:"ProductionYear"` + Genres []string `json:"Genres"` + CommunityRating float64 `json:"CommunityRating"` + RunTimeTicks int64 `json:"RunTimeTicks"` + IndexNumber int `json:"IndexNumber"` + ParentIndexNumber int `json:"ParentIndexNumber"` + Container string `json:"Container"` + MediaStreams []struct { + Type string `json:"Type"` + Codec string `json:"Codec"` + } `json:"MediaStreams"` + Studios []struct { Name string `json:"Name"` } `json:"Studios"` UserData struct { @@ -46,6 +57,38 @@ type Item struct { Raw json.RawMessage `json:"-"` } +func (i Item) RuntimeMinutes() int { + if i.RunTimeTicks <= 0 { + return 0 + } + return int(i.RunTimeTicks / 600_000_000) +} + +func (i Item) TitleKey() string { + value := i.Name + if i.Type == "Episode" && strings.TrimSpace(i.SeriesName) != "" { + value = i.SeriesName + } + var b strings.Builder + for _, r := range strings.ToLower(value) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// SeenKey is a title-level fallback for imported catalogue records, whose payloads +// deliberately contain no per-user UserData. Movies include their year so watching an +// older film does not hide a remake with the same name; episodes collapse to series. +func (i Item) SeenKey() string { + key := i.TitleKey() + if key != "" && strings.EqualFold(i.Type, "Movie") && i.ProductionYear > 0 { + return key + "|" + strconv.Itoa(i.ProductionYear) + } + return key +} + // Seed is a title recent enough to anchor a "Because you watched …" row. type Seed struct { ID string @@ -58,8 +101,9 @@ type Profile struct { StudioWeights map[string]float64 // Seen holds item ids *and* series ids already watched or in progress, so a // recommendation never suggests something the user is already partway through. - Seen map[string]bool - Seeds []Seed + Seen map[string]bool + SeenTitles map[string]bool + Seeds []Seed } func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 } @@ -87,12 +131,25 @@ func BuildProfile(history, favorites []Item) Profile { GenreWeights: map[string]float64{}, StudioWeights: map[string]float64{}, Seen: map[string]bool{}, + SeenTitles: map[string]bool{}, } seedSeen := map[string]bool{} + tasteSeen := map[string]bool{} for i, item := range history { - weight := math.Pow(recencyDecay, float64(i)) - profile.absorb(item, weight) + profile.markSeen(item) + + // Several episodes of one series are evidence for one taste, not several + // independent tastes. Keep the newest occurrence's recency weight and still + // mark every item/series identifier as seen. + tasteID := item.ID + if item.SeriesID != "" { + tasteID = item.SeriesID + } + if !tasteSeen[tasteID] { + tasteSeen[tasteID] = true + profile.absorbTaste(item, math.Pow(recencyDecay, float64(i))) + } // An episode seeds its series, not itself: "Because you watched Severance" // reads better than "Because you watched Good News". @@ -113,12 +170,25 @@ func BuildProfile(history, favorites []Item) Profile { } func (p *Profile) absorb(item Item, weight float64) { + p.markSeen(item) + p.absorbTaste(item, weight) +} + +func (p *Profile) markSeen(item Item) { if item.ID != "" { p.Seen[item.ID] = true } if item.SeriesID != "" { p.Seen[item.SeriesID] = true } + if key := item.SeenKey(); key != "" { + p.SeenTitles[key] = true + } +} + +// absorbTaste learns affinity without marking the item watched. This is used for +// browsing signals: lingering on a card is meaningful, but must not hide that card. +func (p *Profile) absorbTaste(item Item, weight float64) { for _, genre := range item.Genres { if g := strings.TrimSpace(genre); g != "" { p.GenreWeights[g] += weight @@ -168,6 +238,9 @@ func (p Profile) Score(candidate Item) float64 { if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] { return -1 } + if p.SeenTitles[candidate.SeenKey()] { + return -1 + } if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 { return -1 } diff --git a/server/internal/recommend/profile_test.go b/server/internal/recommend/profile_test.go index a641458..c866f29 100644 --- a/server/internal/recommend/profile_test.go +++ b/server/internal/recommend/profile_test.go @@ -59,6 +59,23 @@ func TestBuildProfileDeduplicatesSeeds(t *testing.T) { } } +func TestBuildProfileDoesNotCountEveryEpisodeAsAnotherTasteVote(t *testing.T) { + history := []Item{ + episode("ep2", "Second", "series", "Series", []string{"Drama"}), + episode("ep1", "First", "series", "Series", []string{"Drama"}), + item("movie", "Movie", "Movie", []string{"Comedy"}, 0), + } + profile := BuildProfile(history, nil) + + if profile.GenreWeights["Drama"] != 1 { + t.Fatalf("repeated series weight = %v, want the newest occurrence only", + profile.GenreWeights["Drama"]) + } + if !profile.Seen["ep1"] || !profile.Seen["ep2"] || !profile.Seen["series"] { + t.Fatalf("episode/series exclusions were lost: %+v", profile.Seen) + } +} + func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) { fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil) fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)}) diff --git a/server/internal/store/analytics.go b/server/internal/store/analytics.go index 1b82400..fc84ccc 100644 --- a/server/internal/store/analytics.go +++ b/server/internal/store/analytics.go @@ -2,12 +2,51 @@ package store import ( "context" + "encoding/json" "fmt" "time" "github.com/jackc/pgx/v5" ) +// BrowsingCandidates returns library items the user actively focused or selected, +// strongest first. Impressions are intentionally excluded: merely scrolling past a row +// is not evidence of taste. +func (s *Store) BrowsingCandidates( + ctx context.Context, + userID string, + since time.Time, + limit int, +) ([]json.RawMessage, error) { + rows, err := s.pool.Query(ctx, ` + SELECT li.payload + FROM row_events re + JOIN library_items li ON li.id = re.item_id + WHERE re.emby_user_id = $1 + AND re.occurred_at >= $2 + AND re.event IN ('focus', 'select') + GROUP BY li.id, li.payload + ORDER BY + count(*) FILTER (WHERE re.event = 'select') * 20 + + count(*) FILTER (WHERE re.event = 'focus') * 2 + + coalesce(sum(re.dwell_ms), 0) / 10000 DESC, + max(re.occurred_at) DESC + LIMIT $3`, userID, since, limit) + if err != nil { + return nil, fmt.Errorf("store: browsing candidates: %w", err) + } + defer rows.Close() + out := []json.RawMessage{} + for rows.Next() { + var payload []byte + if err := rows.Scan(&payload); err != nil { + return nil, err + } + out = append(out, json.RawMessage(payload)) + } + return out, rows.Err() +} + // RowEvent is one reported interaction with a home-screen row. type RowEvent struct { OccurredAt time.Time diff --git a/server/internal/store/library.go b/server/internal/store/library.go index 789e93f..4c8a823 100644 --- a/server/internal/store/library.go +++ b/server/internal/store/library.go @@ -138,6 +138,21 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in return collectPayloads(rows) } +// AllRecommendationCandidates returns the complete Movie/Series catalogue for an +// offline For You rebuild. The resulting per-user pool is deliberately over-provisioned +// so a short runtime filter still has enough ranked titles to fill the TV row. +func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error) { + rows, err := s.pool.Query(ctx, ` + SELECT payload + FROM library_items + WHERE type IN ('Movie', 'Series') + ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST`) + if err != nil { + return nil, fmt.Errorf("store: all recommendation candidates: %w", err) + } + return collectPayloads(rows) +} + // CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays // are matched case-insensitively because Emby studio capitalisation is not consistent. func (s *Store) CuratedCandidates( diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index e42c8d1..8be83cc 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -11,11 +11,15 @@ CREATE TABLE IF NOT EXISTS sessions ( server_id TEXT NOT NULL DEFAULT '', device_id TEXT NOT NULL DEFAULT '', device_name TEXT NOT NULL DEFAULT 'Memby TV', + client_version TEXT NOT NULL DEFAULT '', + client_protocol TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV'; +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT ''; +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT ''; -- Older builds could create more than one token for the same physical TV. Keep the most -- recently used row before adding the identity constraint. @@ -102,3 +106,116 @@ CREATE TABLE IF NOT EXISTS row_events ( CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC); CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC); + +-- Search terms are retained separately from row engagement so they can inform future +-- ranking/recommendation work without coupling that analysis to rendered rows. +CREATE TABLE IF NOT EXISTS search_history ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + emby_user_id TEXT NOT NULL, + query TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS search_history_user_time_idx + ON search_history (emby_user_id, occurred_at DESC); + +-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or +-- since cursor, so stable source ids make these rows the durable deduplication boundary. +-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking. +CREATE TABLE IF NOT EXISTS tracearr_sessions ( + server_id TEXT NOT NULL DEFAULT '', + tracearr_session_id TEXT NOT NULL, + tracearr_user_id TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + media_type TEXT NOT NULL DEFAULT '', + media_title TEXT NOT NULL DEFAULT '', + show_title TEXT NOT NULL DEFAULT '', + season_number INT, + episode_number INT, + production_year INT, + started_at TIMESTAMPTZ, + stopped_at TIMESTAMPTZ, + duration_ms BIGINT NOT NULL DEFAULT 0, + progress_ms BIGINT NOT NULL DEFAULT 0, + total_duration_ms BIGINT NOT NULL DEFAULT 0, + watched BOOLEAN NOT NULL DEFAULT false, + device TEXT NOT NULL DEFAULT '', + player TEXT NOT NULL DEFAULT '', + product TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + is_transcode BOOLEAN NOT NULL DEFAULT false, + video_decision TEXT NOT NULL DEFAULT '', + audio_decision TEXT NOT NULL DEFAULT '', + source_video_codec TEXT NOT NULL DEFAULT '', + source_audio_codec TEXT NOT NULL DEFAULT '', + emby_item_id TEXT NOT NULL DEFAULT '', + emby_series_id TEXT NOT NULL DEFAULT '', + source_fingerprint BYTEA NOT NULL, + source_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + imported_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (server_id, tracearr_session_id) +); + +CREATE INDEX IF NOT EXISTS tracearr_sessions_user_time_idx + ON tracearr_sessions (tracearr_user_id, started_at DESC); +CREATE INDEX IF NOT EXISTS tracearr_sessions_username_time_idx + ON tracearr_sessions (lower(username), started_at DESC); +CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_item_idx + ON tracearr_sessions (emby_item_id) WHERE emby_item_id <> ''; +CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_series_idx + ON tracearr_sessions (emby_series_id) WHERE emby_series_id <> ''; + +-- One compact derived profile per Emby user. Variable affinity maps stay together as +-- JSON because the builder reads and replaces the whole profile; no request filters +-- inside these maps. +CREATE TABLE IF NOT EXISTS recommendation_user_profiles ( + emby_user_id TEXT PRIMARY KEY, + tracearr_user_id TEXT NOT NULL DEFAULT '', + tracearr_username TEXT NOT NULL DEFAULT '', + source_session_count INT NOT NULL DEFAULT 0, + mean_completion_ratio REAL NOT NULL DEFAULT 0, + typical_session_minutes INT NOT NULL DEFAULT 0, + genre_affinity JSONB NOT NULL DEFAULT '{}'::jsonb, + title_affinity JSONB NOT NULL DEFAULT '{}'::jsonb, + studio_affinity JSONB NOT NULL DEFAULT '{}'::jsonb, + codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb, + signals_through TIMESTAMPTZ, + built_at TIMESTAMPTZ, + pool_built_at TIMESTAMPTZ, + dirty_since TIMESTAMPTZ DEFAULT now(), + last_error TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx + ON recommendation_user_profiles (dirty_since) + WHERE dirty_since IS NOT NULL; + +-- Every eligible ranked title is retained. At household scale this is only tens of +-- thousands of compact rows and gives short runtime filters far more headroom than the +-- old 240-title request pool. +CREATE TABLE IF NOT EXISTS for_you_candidates ( + emby_user_id TEXT NOT NULL, + item_id TEXT NOT NULL, + base_rank INT NOT NULL, + base_score REAL NOT NULL DEFAULT 0, + runtime_minutes INT NOT NULL DEFAULT 0, + affinity_score REAL NOT NULL DEFAULT 0, + compatibility_score REAL NOT NULL DEFAULT 0, + compatibility_label TEXT NOT NULL DEFAULT '', + reason_kind TEXT NOT NULL DEFAULT '', + reason_genre TEXT NOT NULL DEFAULT '', + reason_source_session_id TEXT NOT NULL DEFAULT '', + reason_source_item_id TEXT NOT NULL DEFAULT '', + reason_source_title TEXT NOT NULL DEFAULT '', + recommendation_reason TEXT NOT NULL DEFAULT '', + built_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (emby_user_id, item_id), + FOREIGN KEY (item_id) REFERENCES library_items(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS for_you_candidates_user_rank_idx + ON for_you_candidates (emby_user_id, base_rank); +CREATE INDEX IF NOT EXISTS for_you_candidates_user_runtime_rank_idx + ON for_you_candidates (emby_user_id, runtime_minutes, base_rank); diff --git a/server/internal/store/settings.go b/server/internal/store/settings.go index 1f257ea..99f6ac2 100644 --- a/server/internal/store/settings.go +++ b/server/internal/store/settings.go @@ -104,10 +104,12 @@ func (s *Store) SetUpdatePolicy(ctx context.Context, policy appupdate.Policy) er func (s *Store) NewestSession(ctx context.Context) (Session, error) { var sess Session err := s.pool.QueryRow(ctx, ` - SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at + SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, + device_name, client_version, client_protocol, last_seen_at FROM sessions ORDER BY last_seen_at DESC LIMIT 1`). Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username, - &sess.ServerID, &sess.DeviceID, &sess.LastSeenAt) + &sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion, + &sess.ClientProtocol, &sess.LastSeenAt) if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrNotFound } diff --git a/server/internal/store/store.go b/server/internal/store/store.go index d6388ec..168c87d 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -20,14 +20,16 @@ var ErrNotFound = errors.New("store: session not found") var ErrDeviceLimit = errors.New("store: device limit reached") type Session struct { - TokenHash []byte - EmbyUserID string - EmbyToken string - Username string - ServerID string - DeviceID string - DeviceName string - LastSeenAt time.Time + TokenHash []byte + EmbyUserID string + EmbyToken string + Username string + ServerID string + DeviceID string + DeviceName string + ClientVersion string + ClientProtocol string + LastSeenAt time.Time } type Store struct { @@ -50,6 +52,58 @@ func (s *Store) Close() { s.pool.Close() } func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } +// RecordSearch stores a normalized query for future per-user ranking analysis. +func (s *Store) RecordSearch(ctx context.Context, userID, query string) error { + _, err := s.pool.Exec(ctx, + `WITH inserted AS ( + INSERT INTO search_history (emby_user_id, query) VALUES ($1, $2) + RETURNING id + ) + DELETE FROM search_history + WHERE emby_user_id = $1 + AND occurred_at < now() - interval '30 days'`, + userID, query) + return err +} + +// RecentSearches returns a user's distinct queries in most-recently-used order. +// Case-only duplicates collapse to the spelling used most recently. +func (s *Store) RecentSearches( + ctx context.Context, + userID string, + since time.Time, + limit int, +) ([]string, error) { + rows, err := s.pool.Query(ctx, ` + SELECT query + FROM ( + SELECT DISTINCT ON (lower(query)) query, occurred_at + FROM search_history + WHERE emby_user_id = $1 AND occurred_at >= $2 + ORDER BY lower(query), occurred_at DESC + ) AS latest + ORDER BY occurred_at DESC + LIMIT $3`, + userID, since, limit) + if err != nil { + return nil, fmt.Errorf("store: recent searches: %w", err) + } + defer rows.Close() + + queries := make([]string, 0, limit) + for rows.Next() { + var query string + if err := rows.Scan(&query); err != nil { + return nil, fmt.Errorf("store: scan recent search: %w", err) + } + queries = append(queries, query) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: read recent searches: %w", err) + } + return queries, nil +} + // Migrate applies the schema. It is idempotent, so it runs on every boot. func (s *Store) Migrate(ctx context.Context) error { if _, err := s.pool.Exec(ctx, schema); err != nil { @@ -94,18 +148,21 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) _, err = tx.Exec(ctx, ` INSERT INTO sessions ( - token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name + token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, + client_version, client_protocol ) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (emby_user_id, device_id) DO UPDATE SET token_hash = EXCLUDED.token_hash, emby_token = EXCLUDED.emby_token, username = EXCLUDED.username, server_id = EXCLUDED.server_id, device_name = EXCLUDED.device_name, + client_version = EXCLUDED.client_version, + client_protocol = EXCLUDED.client_protocol, last_seen_at = now()`, sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, - sess.ServerID, sess.DeviceID, sess.DeviceName) + sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol) if err != nil { return nil, 0, fmt.Errorf("store: create session: %w", err) } @@ -121,10 +178,12 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) { var sess Session err := s.pool.QueryRow(ctx, ` - SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, last_seen_at + SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, + device_name, client_version, client_protocol, last_seen_at FROM sessions WHERE token_hash = $1`, hash). Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username, - &sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt) + &sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion, + &sess.ClientProtocol, &sess.LastSeenAt) if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrNotFound } @@ -141,6 +200,23 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error { return err } +// UpdateSessionClientIdentity remembers the last non-empty identity supplied by a TV. +// Headerless image requests can then still be attributed to the correct app build. +func (s *Store) UpdateSessionClientIdentity( + ctx context.Context, + hash []byte, + version, protocol string, +) error { + _, err := s.pool.Exec(ctx, ` + UPDATE sessions + SET client_version = CASE WHEN $2 <> '' THEN $2 ELSE client_version END, + client_protocol = CASE WHEN $3 <> '' THEN $3 ELSE client_protocol END, + last_seen_at = now() + WHERE token_hash = $1`, + hash, version, protocol) + return err +} + func (s *Store) DeleteSession(ctx context.Context, hash []byte) error { _, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash) return err @@ -176,10 +252,11 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess AND ranked.device_rank > $1 RETURNING current.token_hash, current.emby_user_id, current.emby_token, current.username, current.server_id, current.device_id, - current.device_name, current.last_seen_at + current.device_name, current.client_version, current.client_protocol, + current.last_seen_at ) SELECT token_hash, emby_user_id, emby_token, username, server_id, - device_id, device_name, last_seen_at + device_id, device_name, client_version, client_protocol, last_seen_at FROM retired`, maxClients, ) @@ -193,7 +270,8 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess var sess Session if err := rows.Scan( &sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username, - &sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt, + &sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion, + &sess.ClientProtocol, &sess.LastSeenAt, ); err != nil { return nil, fmt.Errorf("store: scan trimmed session: %w", err) }