0.2.81
This commit is contained in:
@@ -1,3 +1,18 @@
|
|||||||
|
## 0.2.81 - 2026-08-19
|
||||||
|
- Improved: Moved poster media type icon to subtitle on Genres page.
|
||||||
|
- Fixed: Homepage heroes now update as soon as an administrator changes them.
|
||||||
|
- Fixed: Show/movie logos were not cached correctly.
|
||||||
|
- Fixed: Genres list in Genres page sorted by user activity.
|
||||||
|
|
||||||
|
## 0.2.80 - 2026-08-19
|
||||||
|
- New: Hold down on the user switcher to get a "clear all" option for notifications.
|
||||||
|
- Fixed: Improved navigation behaviour on the TV Shows page.
|
||||||
|
- Fixed: Design tweaks/fixes with Genres page.
|
||||||
|
- Removed: Recent Searches feature.
|
||||||
|
|
||||||
|
## 0.2.79 - 2026-08-19
|
||||||
|
- Bug fixes
|
||||||
|
|
||||||
## 0.2.78 - 2026-08-19
|
## 0.2.78 - 2026-08-19
|
||||||
- Removed: Genres row from the TV Series and Movies pages. Replaced by Genres page.
|
- Removed: Genres row from the TV Series and Movies pages. Replaced by Genres page.
|
||||||
- Improved: Added Mark as Watched button to Series detail pages.
|
- Improved: Added Mark as Watched button to Series detail pages.
|
||||||
|
|||||||
@@ -809,6 +809,53 @@ at a time. Things to preserve:
|
|||||||
`HomeViewModel.favoriteChanges` so a Favourite press and its possible rollback reach the
|
`HomeViewModel.favoriteChanges` so a Favourite press and its possible rollback reach the
|
||||||
paged copy immediately. Its `type=Movie` / `type=Series` query is still enforced by the
|
paged copy immediately. Its `type=Movie` / `type=Series` query is still enforced by the
|
||||||
gateway and on the direct Emby path, so no category can mix the two grids.
|
gateway and on the direct Emby path, so no category can mix the two grids.
|
||||||
|
- **The rail is ordered by what this viewer watches, and the catalogue itself never
|
||||||
|
changes.** `GET /v1/genres/affinity` (`server/internal/api/genre_affinity.go` →
|
||||||
|
`store.TracearrGenreAffinity` → `ui/genre/GenrePersonalisation.kt`) is a weight per Emby
|
||||||
|
genre label, and `personaliseGenreCategories` is what the television does with it. Things
|
||||||
|
to preserve:
|
||||||
|
- **The evidence is the server's and the catalogue is the set's.** Nothing is stored:
|
||||||
|
Tracearr's sessions and the imported catalogue's genres are already in Postgres, so the
|
||||||
|
reading is a join over two tables the gateway keeps for other reasons — the trade
|
||||||
|
`watchedMsExpr` makes for watch time, and for the same reason (a per-user genre table
|
||||||
|
would be a copy of both, wrong the moment either changed). The gateway sends Emby's own
|
||||||
|
spellings verbatim and never a category: folding "Science Fiction", "Sci-Fi" and
|
||||||
|
"Sci-Fi & Fantasy" into one shelf needs the alias table, which lives on the television,
|
||||||
|
and a second copy of it on the server would disagree the first time a category gained a
|
||||||
|
spelling — which is the failure the aliases exist to fix.
|
||||||
|
- **The order is settled before the rail is drawn and never re-ordered under a viewer.**
|
||||||
|
`EmbyRepository.genreAffinitySnapshot()` is a volatile read, the `snapshot` arrangement,
|
||||||
|
consulted once in `GenreBrowseViewModel`'s constructor; `warmGenreAffinity()` is what
|
||||||
|
fills it in, from `HomeViewModel`'s init, because the browser is never the first screen
|
||||||
|
a television draws. A set whose reading has not landed gets the catalogue order and is
|
||||||
|
personalised the next time the page is opened, which is the right way round: a rail that
|
||||||
|
reshuffled under a D-pad already travelling it is worse than one that never personalised.
|
||||||
|
- **Nothing is ever hidden, and the unpersonalised half stays in product order.** The sort
|
||||||
|
is stable, so a category with no evidence scores zero and keeps its place below the ones
|
||||||
|
the viewer watches. `MIN_GENRE_AFFINITY_SESSIONS` refuses to personalise at all below a
|
||||||
|
floor — one Western on a wet Sunday is not a taste — and `GENRE_AFFINITY_FLOOR` is a
|
||||||
|
*share* of the top genre rather than a count, so the long tail cannot reshuffle on noise
|
||||||
|
and a household that watches nightly and one that watches on Sundays order the same way.
|
||||||
|
- **An episode counts as its series, and an abandoned title counts as nothing.** The
|
||||||
|
session joins on `emby_series_id` before `emby_item_id`, so a household watching one
|
||||||
|
crime drama nightly reads as watching crime; `genreAffinityEngagement` requires half the
|
||||||
|
title, because a session that stopped four minutes in is evidence somebody did *not*
|
||||||
|
want it and counting those is how a rail comes to lead with what a viewer keeps
|
||||||
|
abandoning. Recency is three flat bands over 180 days rather than a decay — a half-life
|
||||||
|
nobody could defend would also make the answer move between two readings taken the same
|
||||||
|
evening.
|
||||||
|
- **Every failure is the default order.** The direct path has nobody to ask, a gateway
|
||||||
|
predating the route answers 404, a household running no Tracearr has no history, and the
|
||||||
|
operator's Tracearr switch is honoured even though the rows are already in Postgres —
|
||||||
|
switched off means Memby may not read their viewing, and old rows are still their
|
||||||
|
viewing. There is deliberately no second implementation on the direct path, the stance
|
||||||
|
the TV calendar takes: the answer is Tracearr's, which a television holds no credential
|
||||||
|
for, and the catalogue's own order is a perfectly good rail.
|
||||||
|
- **Six hours on both sides.** `genreAffinityTTL` and `GENRE_AFFINITY_REFRESH_MS` are
|
||||||
|
matched so a set refetching past it asks a question that has actually been recomputed
|
||||||
|
rather than paying a round trip for a cached repeat; an empty reading is kept for an
|
||||||
|
hour instead, because a new account genuinely acquires a history and a six-hour no would
|
||||||
|
hide their first evening of watching until the morning.
|
||||||
- **The gateway path degrades to a keyword search on the *first* page only**, so a set on a
|
- **The gateway path degrades to a keyword search on the *first* page only**, so a set on a
|
||||||
new build talking to a gateway that predates the route still shows something. A later page
|
new build talking to a gateway that predates the route still shows something. A later page
|
||||||
does not: a gateway that answered page one and failed on page two is having trouble, not
|
does not: a gateway that answered page one and failed on page two is having trouble, not
|
||||||
@@ -2513,6 +2560,40 @@ frame. Don't construct a bare `OkHttpClient.Builder()` — derive from `HttpStac
|
|||||||
stream's derived client raises the read timeout and sets **no call timeout**, which would
|
stream's derived client raises the read timeout and sets **no call timeout**, which would
|
||||||
cap the length of a film.
|
cap the length of a film.
|
||||||
|
|
||||||
|
**A logo is fetched once and judged once.** `ui/TitleLogo.kt` holds both halves, and both
|
||||||
|
were being repeated on every screen that drew a title treatment — visible to a viewer as a
|
||||||
|
detail page returned to a second time showing the show's *name* and then swapping to its
|
||||||
|
logo. Things to preserve:
|
||||||
|
|
||||||
|
- **The darkness probe must not use Coil's memory cache.** That cache is keyed on the
|
||||||
|
image's URL and **not** on the size it was decoded at, so the 64×64 inspection copy was
|
||||||
|
written over the full-size bitmap the launcher had just cached: every page that probed a
|
||||||
|
logo threw away the copy the previous page had paid for, and the next display request
|
||||||
|
decoded it again. `probeLogoDarkness` sets `memoryCachePolicy(CachePolicy.DISABLED)` and
|
||||||
|
leaves the *disk* cache on deliberately — the bytes are worth keeping, and they are what
|
||||||
|
the display request reads instead of the network.
|
||||||
|
- **The verdict is remembered, and `useTextTitleForLogo` reads it as its initial value.**
|
||||||
|
`produceState` starting from "use the text title" is the right default the first time a
|
||||||
|
title is seen and a visible flash every time after it, since the answer cannot change.
|
||||||
|
`TitleLogoCache` is process-scoped, access-ordered and capped; concurrent asks for one
|
||||||
|
logo share a single probe on the cache's *own* scope, because the callers are a detail
|
||||||
|
page's `produceState` and a focus warm and both die when the D-pad moves.
|
||||||
|
- **"Could not tell" is not remembered.** A failed or cancelled probe returns null and is
|
||||||
|
not recorded — a hiccup must not condemn a title to its text heading for the life of the
|
||||||
|
process. `TitleLogoCacheTest` pins that, the single flight and the eviction.
|
||||||
|
- **One width, `data/LOGO_MAX_WIDTH` (720).** It was four — 800 in the screensaver, 720 on
|
||||||
|
the detail page and in the player, 640 on the home hero, 420 in the TV calendar — and a
|
||||||
|
width is part of the URL, so one show's logo was four cache keys, four fetches and four
|
||||||
|
verdicts. Ask for the default; a new surface that names its own width quietly opts out of
|
||||||
|
every cache the others share.
|
||||||
|
- **Invalidation is free and must stay that way.** An Emby logo URL carries the item id
|
||||||
|
*and* the image tag, so artwork replaced on the server is a different key in the verdict
|
||||||
|
cache and in both of Coil's. Nothing is told about it and nothing needs to be.
|
||||||
|
- **It is warmed on focus** (`HomeViewModel.warmDetailPage`), which is where the bytes and
|
||||||
|
the verdict both come from by the time a press composes the page. Skipped outright when
|
||||||
|
the viewer has logos switched off, and `TitleLogoCache.install` in `MembyApp` is what
|
||||||
|
gives that warm an application context rather than making the ViewModel hold one.
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
**Everything a person reads is New Zealand English.** No American spellings: `-ise`/
|
**Everything a person reads is New Zealand English.** No American spellings: `-ise`/
|
||||||
@@ -2939,6 +3020,41 @@ returns nothing rather than throwing. Things to preserve:
|
|||||||
constantly behind it, which is why one slot must always yield the same draw.
|
constantly behind it, which is why one slot must always yield the same draw.
|
||||||
The rotation further down belongs to the direct path, which has no merit to rank by.
|
The rotation further down belongs to the direct path, which has no merit to rank by.
|
||||||
|
|
||||||
|
**An operator's hero change is live, and it rides the status poll to get there.**
|
||||||
|
`heroRevision` (`server/internal/api/hero_revision.go`) is the `themeRevision` shape — a
|
||||||
|
hash rather than a counter, because what a hero resolves to changes when nobody has
|
||||||
|
written anything: a schedule window opens, the rotation slot turns over. `/v1/status`
|
||||||
|
carries it as `hero: {revision}`, `MaintenanceMonitor.heroRevision` publishes it, and the
|
||||||
|
launcher refetches `/v1/heroes/active` when it moves. Before it, a pin reached a set only
|
||||||
|
at its next cold start: the section heroes were fetched once per session and never again,
|
||||||
|
and Home's hero arrives with rows the launcher does not re-request while somebody is
|
||||||
|
sitting on them. Things to preserve:
|
||||||
|
|
||||||
|
- **The revision hashes what would be *resolved*, not the stored document.** `UpdatedAt`
|
||||||
|
moves on every write, so hashing the row would repaint every launcher in the house for a
|
||||||
|
save that changed nothing. For the same reason a schedule added for tomorrow evening does
|
||||||
|
not move it — only the schedules `activeHeroScheduleIDs` says are in force now do, which
|
||||||
|
is also what makes a window opening at 20:00 arrive by itself.
|
||||||
|
- **It is the same value in the cache key as on the poll**, in `handleHome` (`:hr`) and in
|
||||||
|
`hero:active:v2:`. That is what leaves no state in which the poll says something moved and
|
||||||
|
the fetch that follows returns what the set already had — and it replaced the cache
|
||||||
|
*sweep*: saving a hero used to call `InvalidateUser` for every account in the house,
|
||||||
|
throwing away every cached item lookup, row and image the household held in order to
|
||||||
|
change four cards. Nothing is invalidated now; the entries built under the old policy are
|
||||||
|
simply no longer named, and age out on their own TTL.
|
||||||
|
- **The policy is read through `currentHeroPolicy`**, never `store.HeroPolicy` directly.
|
||||||
|
The status poll is every open television every ten seconds, so this document needed the
|
||||||
|
`featurePolicyCache` treatment; the admin save invalidates that copy, which is the only
|
||||||
|
thing that write now does besides writing.
|
||||||
|
- **Home is not refetched at launch, only when the revision has moved under a set already
|
||||||
|
looking at it.** Its hero arrives with the home payload, and fetching it separately on
|
||||||
|
every launch would replace a hero already on screen with a second composition of the same
|
||||||
|
thing. The refetch replaces one placement at a time and never clears first, so unchanged
|
||||||
|
cards keep the artwork already decoded for them and nothing blanks between the two states.
|
||||||
|
- **The revision is collected inside the effect, not read as state in the launcher's body.**
|
||||||
|
A number arriving on a poll must never be a reason the launcher recomposes — the same rule
|
||||||
|
the animation and flow-collection conventions state.
|
||||||
|
|
||||||
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
|
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
|
||||||
card's text column is what gives way when a title wraps onto two lines, so whatever is last
|
card's text column is what gives way when a title wraps onto two lines, so whatever is last
|
||||||
in it is cut — with the reason below the strip, the one line explaining why this card leads
|
in it is cut — with the reason below the strip, the one line explaining why this card leads
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.80"
|
val defaultVersionName = "0.2.81"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import coil.disk.DiskCache
|
|||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||||
import com.ponzischeme89.memby.performance.StartupTrace
|
import com.ponzischeme89.memby.performance.StartupTrace
|
||||||
|
import com.ponzischeme89.memby.ui.TitleLogoCache
|
||||||
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -51,6 +52,9 @@ class MembyApp : Application() {
|
|||||||
.crossfade(false)
|
.crossfade(false)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
// A logo's darkness verdict and the address of a warm are both process-scoped, and
|
||||||
|
// the launcher warms logos from a ViewModel that holds no context of its own.
|
||||||
|
TitleLogoCache.install(this)
|
||||||
ServiceLocator.init(this)
|
ServiceLocator.init(this)
|
||||||
// Registers an idle callback only: the local four-second clip is prepared after
|
// Registers an idle callback only: the local four-second clip is prepared after
|
||||||
// the launcher's queued start-up work, never on its critical path.
|
// the launcher's queued start-up work, never on its critical path.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.ponzischeme89.memby.data
|
|||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import android.os.SystemClock
|
||||||
|
import com.ponzischeme89.memby.data.model.GenreAffinity
|
||||||
import com.ponzischeme89.memby.data.model.GatewayCalendar
|
import com.ponzischeme89.memby.data.model.GatewayCalendar
|
||||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayDevice
|
import com.ponzischeme89.memby.data.model.GatewayDevice
|
||||||
@@ -372,6 +374,27 @@ class EmbyRepository internal constructor(
|
|||||||
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
|
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
|
||||||
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
|
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this viewer watches, for the order of the Genres browser's rail.
|
||||||
|
*
|
||||||
|
* Held as a plain volatile rather than behind a suspend call because the reader is a
|
||||||
|
* ViewModel's constructor — the same reason [snapshot] exists. A screen that had to
|
||||||
|
* await this before drawing its rail would be a screen whose genre list appears late,
|
||||||
|
* and re-ordering a rail somebody is already looking at is worse than not personalising
|
||||||
|
* it at all.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var genreAffinity: GenreAffinity = GenreAffinity()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When that reading was taken, in [SystemClock.elapsedRealtime], so a television
|
||||||
|
* correcting its clock cannot make an hour-old answer look like a fresh one.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var genreAffinityReadAt = 0L
|
||||||
|
private val genreAffinityMutex = Mutex()
|
||||||
|
private var genreAffinityInFlight: Deferred<GenreAffinity?>? = null
|
||||||
|
|
||||||
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||||
|
|
||||||
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
|
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
|
||||||
@@ -471,6 +494,7 @@ class EmbyRepository internal constructor(
|
|||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
clearSeriesEpisodeCache()
|
clearSeriesEpisodeCache()
|
||||||
clearLocalResume()
|
clearLocalResume()
|
||||||
|
clearGenreAffinity()
|
||||||
settings.ensureDeviceId()
|
settings.ensureDeviceId()
|
||||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||||
|
|
||||||
@@ -563,6 +587,7 @@ class EmbyRepository internal constructor(
|
|||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
clearSeriesEpisodeCache()
|
clearSeriesEpisodeCache()
|
||||||
clearLocalResume()
|
clearLocalResume()
|
||||||
|
clearGenreAffinity()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signOut() {
|
suspend fun signOut() {
|
||||||
@@ -578,6 +603,7 @@ class EmbyRepository internal constructor(
|
|||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
clearSeriesEpisodeCache()
|
clearSeriesEpisodeCache()
|
||||||
clearLocalResume()
|
clearLocalResume()
|
||||||
|
clearGenreAffinity()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun switchProfile(profile: EmbyProfile) {
|
suspend fun switchProfile(profile: EmbyProfile) {
|
||||||
@@ -588,6 +614,7 @@ class EmbyRepository internal constructor(
|
|||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
clearSeriesEpisodeCache()
|
clearSeriesEpisodeCache()
|
||||||
clearLocalResume()
|
clearLocalResume()
|
||||||
|
clearGenreAffinity()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeProfile(profile: EmbyProfile) {
|
suspend fun removeProfile(profile: EmbyProfile) {
|
||||||
@@ -605,6 +632,8 @@ class EmbyRepository internal constructor(
|
|||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
clearSeriesEpisodeCache()
|
clearSeriesEpisodeCache()
|
||||||
clearLocalResume()
|
clearLocalResume()
|
||||||
|
clearGenreAffinity()
|
||||||
|
clearGenreAffinity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +939,89 @@ class EmbyRepository internal constructor(
|
|||||||
return GenrePage(items, offset, total)
|
return GenrePage(items, offset, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which genres this viewer actually watches, for the order of the Genres browser's rail.
|
||||||
|
*
|
||||||
|
* The answer comes from the gateway because that is where the evidence is: Tracearr's
|
||||||
|
* sessions and the imported catalogue's genres are both already in Postgres, and a
|
||||||
|
* television holds neither. It is a weight per Emby genre label and nothing more — the
|
||||||
|
* category catalogue, its aliases and its product order all stay on the set, which is
|
||||||
|
* what stops the two ends needing to agree about a list only one of them draws.
|
||||||
|
*
|
||||||
|
* Deliberately *not* on the path of drawing anything. [genreAffinitySnapshot] is what
|
||||||
|
* the rail reads, and it returns whatever is already known; this is what fills that in,
|
||||||
|
* behind the viewer. A genre browser that waited on a request before showing its
|
||||||
|
* categories would have paid for personalisation with the one thing it was told not to
|
||||||
|
* cost.
|
||||||
|
*
|
||||||
|
* Never throws, and every way it can fail produces the same empty answer: the direct
|
||||||
|
* path has nobody to ask, a gateway that predates the route answers 404, a household
|
||||||
|
* running no Tracearr has no history, and an operator may have switched it off. All four
|
||||||
|
* are "use the catalogue's own order", which is a perfectly good rail.
|
||||||
|
*/
|
||||||
|
suspend fun getGenreAffinity(): GenreAffinity {
|
||||||
|
if (!ServerConfig.isGateway) return GenreAffinity()
|
||||||
|
val inFlight = genreAffinityMutex.withLock {
|
||||||
|
if (!genreAffinityStale()) return genreAffinity
|
||||||
|
genreAffinityInFlight ?: newGenreAffinityRequest()
|
||||||
|
}
|
||||||
|
// A failed reading leaves the previous one standing rather than clearing it: an
|
||||||
|
// unreachable gateway is no evidence that somebody's taste has changed.
|
||||||
|
return inFlight.await() ?: genreAffinity
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reading already in hand, with no request and no suspension.
|
||||||
|
*
|
||||||
|
* This is what the Genres browser's ViewModel reads when it is constructed, the
|
||||||
|
* [snapshot] arrangement. The rail is ordered once, from this, and never re-ordered
|
||||||
|
* while somebody is looking at it — a list that reshuffled under a D-pad would be worse
|
||||||
|
* than one that was never personalised, and the warm below is what makes it almost
|
||||||
|
* always already here.
|
||||||
|
*/
|
||||||
|
fun genreAffinitySnapshot(): GenreAffinity = genreAffinity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the reading if it is missing or stale, and returns immediately.
|
||||||
|
*
|
||||||
|
* Called as the launcher settles, because the Genres browser is never the first thing a
|
||||||
|
* television draws — a viewer has to travel the rail to reach it — so by the time it is
|
||||||
|
* opened this has long since landed. The cost is one request per six hours per set.
|
||||||
|
*/
|
||||||
|
fun warmGenreAffinity() {
|
||||||
|
if (!ServerConfig.isGateway) return
|
||||||
|
scope.launch { runCatching { getGenreAffinity() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun genreAffinityStale(): Boolean =
|
||||||
|
genreAffinityReadAt == 0L ||
|
||||||
|
SystemClock.elapsedRealtime() - genreAffinityReadAt >= GENRE_AFFINITY_REFRESH_MS
|
||||||
|
|
||||||
|
private fun newGenreAffinityRequest(): Deferred<GenreAffinity?> {
|
||||||
|
val request = scope.async(start = CoroutineStart.LAZY) {
|
||||||
|
try {
|
||||||
|
val loaded = runCatching { requireGateway().genreAffinity() }.getOrNull()
|
||||||
|
?: return@async null
|
||||||
|
genreAffinityMutex.withLock {
|
||||||
|
genreAffinity = loaded
|
||||||
|
genreAffinityReadAt = SystemClock.elapsedRealtime()
|
||||||
|
loaded
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
genreAffinityMutex.withLock { genreAffinityInFlight = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genreAffinityInFlight = request
|
||||||
|
request.start()
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another viewer's taste is not this one's; cleared wherever the session changes. */
|
||||||
|
private fun clearGenreAffinity() {
|
||||||
|
genreAffinity = GenreAffinity()
|
||||||
|
genreAffinityReadAt = 0L
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One stable, paged slice of all films, all series, or both.
|
* One stable, paged slice of all films, all series, or both.
|
||||||
*
|
*
|
||||||
@@ -3135,7 +3247,7 @@ class EmbyRepository internal constructor(
|
|||||||
* or null when the item has no logo. Used to show artwork in place of the
|
* or null when the item has no logo. Used to show artwork in place of the
|
||||||
* plain-text title in the screensaver.
|
* plain-text title in the screensaver.
|
||||||
*/
|
*/
|
||||||
fun logoUrl(item: BaseItem, maxWidth: Int = 800): String? {
|
fun logoUrl(item: BaseItem, maxWidth: Int = LOGO_MAX_WIDTH): String? {
|
||||||
val (id, tag) = when {
|
val (id, tag) = when {
|
||||||
item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo")
|
item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo")
|
||||||
item.parentLogoItemId != null && item.parentLogoImageTag != null ->
|
item.parentLogoItemId != null && item.parentLogoImageTag != null ->
|
||||||
@@ -3457,6 +3569,18 @@ data class ChapterMarkers(
|
|||||||
/** An empty reading is a real answer — most titles have no markers — so it is cached too. */
|
/** An empty reading is a real answer — most titles have no markers — so it is cached too. */
|
||||||
private data class CachedIntro(val value: ChapterMarkers)
|
private data class CachedIntro(val value: ChapterMarkers)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one width every surface asks Emby for a logo at.
|
||||||
|
*
|
||||||
|
* It used to be four — 800 for the screensaver, 720 for the detail page and the player, 640
|
||||||
|
* for the home hero, 420 for the TV calendar — and a width is part of the URL, so one show's
|
||||||
|
* title treatment was four cache keys, four fetches and four darkness verdicts. One width
|
||||||
|
* makes it one entry in Coil's memory and disk caches and one entry in `TitleLogoCache`,
|
||||||
|
* shared by every screen that draws it. 720px comfortably covers the largest stage in the
|
||||||
|
* app (the screensaver's 320dp) on a 1080p television.
|
||||||
|
*/
|
||||||
|
const val LOGO_MAX_WIDTH = 720
|
||||||
|
|
||||||
private const val PLAYABLE_CACHE_SIZE = 16
|
private const val PLAYABLE_CACHE_SIZE = 16
|
||||||
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||||
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
|
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ data class GenrePage(
|
|||||||
/** A page this size, in items. Several television screenfuls, so the scroll stays ahead. */
|
/** A page this size, in items. Several television screenfuls, so the scroll stays ahead. */
|
||||||
const val GENRE_PAGE_SIZE = 48
|
const val GENRE_PAGE_SIZE = 48
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a viewer's genre affinity is good for on the television.
|
||||||
|
*
|
||||||
|
* Matched to the gateway's own cache lifetime, so a set that refetches on the far side of it
|
||||||
|
* is asking a question that has actually been recomputed rather than paying a round trip for
|
||||||
|
* a cached repeat of what it already holds. Taste does not move in an evening; what this
|
||||||
|
* span buys is that a household whose viewing changes sees the rail follow it within a day.
|
||||||
|
*/
|
||||||
|
const val GENRE_AFFINITY_REFRESH_MS = 6L * 60L * 60L * 1000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the grid should ask for another page.
|
* Whether the grid should ask for another page.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
|
|||||||
|
|
||||||
private val _preferencesRevision = MutableStateFlow(0L)
|
private val _preferencesRevision = MutableStateFlow(0L)
|
||||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||||
|
private val _heroRevision = MutableStateFlow("")
|
||||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||||
private val _genreBrowserEnabled = MutableStateFlow(false)
|
private val _genreBrowserEnabled = MutableStateFlow(false)
|
||||||
private val _tvCalendarEnabled = MutableStateFlow(false)
|
private val _tvCalendarEnabled = MutableStateFlow(false)
|
||||||
@@ -107,6 +108,19 @@ class MaintenanceMonitor(
|
|||||||
*/
|
*/
|
||||||
val theme: StateFlow<GatewayThemeStatus> = _theme.asStateFlow()
|
val theme: StateFlow<GatewayThemeStatus> = _theme.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revision of the hero configuration the gateway would compose for this viewer.
|
||||||
|
*
|
||||||
|
* The launcher refetches its heroes only when this moves, which is what makes an
|
||||||
|
* operator pinning a title arrive on the poll a set is already making rather than at
|
||||||
|
* the next cold start. It rides this poll for the theme's reason: the change has to
|
||||||
|
* reach a television that is already switched on and sitting on the home screen.
|
||||||
|
*
|
||||||
|
* Blank whenever nothing has said otherwise — signed out, on the direct path, or on a
|
||||||
|
* gateway that predates the field — and blank never triggers a refetch.
|
||||||
|
*/
|
||||||
|
val heroRevision: StateFlow<String> = _heroRevision.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The viewer's server-held settings revision, as of the last successful poll. This is
|
* The viewer's server-held settings revision, as of the last successful poll. This is
|
||||||
* how an operator's push reaches a television: the number changes, [PreferencesSync]
|
* how an operator's push reaches a television: the number changes, [PreferencesSync]
|
||||||
@@ -242,6 +256,7 @@ class MaintenanceMonitor(
|
|||||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
_theme.value = GatewayThemeStatus()
|
_theme.value = GatewayThemeStatus()
|
||||||
|
_heroRevision.value = ""
|
||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
_genreBrowserEnabled.value = false
|
_genreBrowserEnabled.value = false
|
||||||
_tvCalendarEnabled.value = false
|
_tvCalendarEnabled.value = false
|
||||||
@@ -275,6 +290,7 @@ class MaintenanceMonitor(
|
|||||||
}
|
}
|
||||||
_preferencesRevision.value = status.preferencesRevision
|
_preferencesRevision.value = status.preferencesRevision
|
||||||
_theme.value = status.theme
|
_theme.value = status.theme
|
||||||
|
_heroRevision.value = status.hero.revision
|
||||||
_installPermissionPrompt.value =
|
_installPermissionPrompt.value =
|
||||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||||
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
||||||
@@ -314,6 +330,7 @@ class MaintenanceMonitor(
|
|||||||
_embyOutage.value = null
|
_embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
_theme.value = GatewayThemeStatus()
|
_theme.value = GatewayThemeStatus()
|
||||||
|
_heroRevision.value = ""
|
||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
_genreBrowserEnabled.value = false
|
_genreBrowserEnabled.value = false
|
||||||
_tvCalendarEnabled.value = false
|
_tvCalendarEnabled.value = false
|
||||||
|
|||||||
@@ -201,11 +201,34 @@ data class GatewayServiceStatus(
|
|||||||
* the switcher rather than offering a menu item its own handlers would refuse.
|
* the switcher rather than offering a menu item its own handlers would refuse.
|
||||||
*/
|
*/
|
||||||
val requests: GatewayRequestAccess = GatewayRequestAccess(),
|
val requests: GatewayRequestAccess = GatewayRequestAccess(),
|
||||||
|
/**
|
||||||
|
* The hero configuration this viewer's televisions should be drawing, as one opaque
|
||||||
|
* revision rather than the cards themselves — the [theme] precedent, for the same
|
||||||
|
* reason: this poll runs every ten seconds on every open set.
|
||||||
|
*
|
||||||
|
* It moves when an operator pins, removes or reorders a hero, when a scheduled hero
|
||||||
|
* comes into or goes out of force, and when the gateway's rotation slot turns over. A
|
||||||
|
* server that predates it sends none, which decodes to "" and never triggers a refetch:
|
||||||
|
* a set that cannot be told keeps the hero it was given with its home rows, which is
|
||||||
|
* exactly how the launcher behaved before this existed.
|
||||||
|
*/
|
||||||
|
val hero: GatewayHeroStatus = GatewayHeroStatus(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayRequestAccess(val allowed: Boolean = false)
|
data class GatewayRequestAccess(val allowed: Boolean = false)
|
||||||
|
|
||||||
|
/** The summary of the hero configuration that rides the status poll. */
|
||||||
|
@Serializable
|
||||||
|
data class GatewayHeroStatus(
|
||||||
|
/**
|
||||||
|
* Opaque, and compared only for equality. It is also what keys the gateway's cached
|
||||||
|
* hero answers, so a revision that has moved always names a freshly composed hero
|
||||||
|
* rather than the entry the television already holds.
|
||||||
|
*/
|
||||||
|
val revision: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
|
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayThemeStatus(
|
data class GatewayThemeStatus(
|
||||||
@@ -540,6 +563,40 @@ data class GatewayGenrePage(
|
|||||||
val total: Int = 0,
|
val total: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One genre label and how much of this viewer's watching it accounts for.
|
||||||
|
*
|
||||||
|
* [genre] is Emby's own spelling, verbatim — "Science Fiction", "Sci-Fi & Fantasy" and
|
||||||
|
* "Sci Fi" all arrive as themselves. Folding them is the television's job, because the
|
||||||
|
* television is what owns the category catalogue and its aliases; a gateway that folded
|
||||||
|
* them would need a second copy of that list and the two would disagree the first time a
|
||||||
|
* category gained a spelling.
|
||||||
|
*
|
||||||
|
* [weight] is scaled so the most-watched genre is 1, which is what lets the ordering rule be
|
||||||
|
* written in shares rather than in counts.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class GenreWeight(
|
||||||
|
val genre: String = "",
|
||||||
|
val weight: Double = 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `GET /v1/genres/affinity` — which genres this viewer actually watches,
|
||||||
|
* derived from Tracearr's sessions and the imported catalogue's genres.
|
||||||
|
*
|
||||||
|
* [sessions] is the evidence behind the weights, and it is on the wire because the ordering
|
||||||
|
* rule refuses to personalise below a floor: a share of three sessions is not a taste. Every
|
||||||
|
* field defaults to nothing, so a gateway that predates this route, a household running no
|
||||||
|
* Tracearr and an operator who has switched it off all produce the same answer — the
|
||||||
|
* catalogue's own order.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class GenreAffinity(
|
||||||
|
val sessions: Int = 0,
|
||||||
|
val genres: List<GenreWeight> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
|
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ interface GatewayApi {
|
|||||||
@Query("type") itemType: String? = null,
|
@Query("type") itemType: String? = null,
|
||||||
): com.ponzischeme89.memby.data.model.GatewayGenrePage
|
): com.ponzischeme89.memby.data.model.GatewayGenrePage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which genres this viewer watches, for the order of the Genres browser's rail.
|
||||||
|
*
|
||||||
|
* Asked for at most once per session and never on the path of drawing anything: the
|
||||||
|
* rail has a perfectly good default order, and this only decides which end of it a
|
||||||
|
* household sees first.
|
||||||
|
*/
|
||||||
|
@GET("v1/genres/affinity")
|
||||||
|
suspend fun genreAffinity(): com.ponzischeme89.memby.data.model.GenreAffinity
|
||||||
|
|
||||||
/** One paged media-type shelf without a genre filter. */
|
/** One paged media-type shelf without a genre filter. */
|
||||||
@GET("v1/library/items")
|
@GET("v1/library/items")
|
||||||
suspend fun libraryItems(
|
suspend fun libraryItems(
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class MembyDreamService : DreamService() {
|
|||||||
TrailerPlaybackRequest(
|
TrailerPlaybackRequest(
|
||||||
subjectId = item.id,
|
subjectId = item.id,
|
||||||
title = item.name,
|
title = item.name,
|
||||||
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
|
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ private fun DetailHero(
|
|||||||
// treatment on this near-black scrim is an invisible heading.
|
// treatment on this near-black scrim is an invisible heading.
|
||||||
val repository = ServiceLocator.repository
|
val repository = ServiceLocator.repository
|
||||||
val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
|
val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
|
||||||
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
|
if (repository.showTitleLogo) repository.logoUrl(item) else null
|
||||||
}
|
}
|
||||||
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
||||||
val hasRatings = remember(ratings, showRatingsStrip) {
|
val hasRatings = remember(ratings, showRatingsStrip) {
|
||||||
@@ -964,10 +964,9 @@ private fun DetailIdentity(
|
|||||||
contentAlignment = Alignment.BottomStart,
|
contentAlignment = Alignment.BottomStart,
|
||||||
) {
|
) {
|
||||||
if (logo != null) {
|
if (logo != null) {
|
||||||
AsyncImage(
|
TitleLogoImage(
|
||||||
model = logo,
|
logoUrl = logo,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
alignment = Alignment.BottomStart,
|
alignment = Alignment.BottomStart,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(DetailHeroMetrics.LogoMaxWidth)
|
.width(DetailHeroMetrics.LogoMaxWidth)
|
||||||
|
|||||||
@@ -1526,13 +1526,27 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The size of the media-type mark on a card's metadata line.
|
||||||
|
*
|
||||||
|
* Deliberately smaller than any icon a viewer can aim at — the rail's marks are 21dp and
|
||||||
|
* the circular detail actions larger still. This one is read, never pressed, and at 13dp
|
||||||
|
* it sits inside the 12sp line's own height, so a card carrying it is exactly as tall as
|
||||||
|
* one that does not.
|
||||||
|
*/
|
||||||
|
private val MediaTypeMarkSize = 13.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of the two kinds of thing a card is, for a grid that holds both.
|
* Which of the two kinds of thing a card is, for a grid that holds both.
|
||||||
*
|
*
|
||||||
* Only the Genres destination genuinely mixes them — the Movies and TV Shows pages name a
|
* Only the Genres destination genuinely mixes them — the Movies and TV Shows pages name a
|
||||||
* type, so every card on those wears the same mark and it says nothing. It is a *mark*
|
* type, so every card on those wears the same mark and it says nothing. It is a *mark*
|
||||||
* rather than a word because it sits over artwork at three metres, where "TV SHOW" in a
|
* rather than a word because the metadata line is one line at three metres, and "TV SHOW"
|
||||||
* pill would be read before the poster it is covering.
|
* spelled out there would crowd out the year and the runtime it sits beside.
|
||||||
|
*
|
||||||
|
* It belongs to that line rather than to the poster: the type is informational in the same
|
||||||
|
* way the year and the runtime are, and grouping the three together leaves the artwork —
|
||||||
|
* the only thing on the card worth looking at — carrying nothing it did not come with.
|
||||||
*
|
*
|
||||||
* A season or an episode is marked as television: both are parts of a show, and a viewer
|
* A season or an episode is marked as television: both are parts of a show, and a viewer
|
||||||
* separating films from shows is not asking about the difference between them. Anything
|
* separating films from shows is not asking about the difference between them. Anything
|
||||||
@@ -2012,7 +2026,7 @@ private fun MediaCard(
|
|||||||
onFocused = onFocused,
|
onFocused = onFocused,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount),
|
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount, showMediaTypeIcon),
|
||||||
modifier = modifier.width(width),
|
modifier = modifier.width(width),
|
||||||
) { focused ->
|
) { focused ->
|
||||||
Column {
|
Column {
|
||||||
@@ -2119,19 +2133,6 @@ private fun MediaCard(
|
|||||||
airingLabel,
|
airingLabel,
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||||
)
|
)
|
||||||
} else if (showMediaTypeIcon) {
|
|
||||||
// Which of the two things this is, for a grid that holds both. The
|
|
||||||
// top-left is the airing badge's corner first: a schedule card already
|
|
||||||
// says what it is by saying when it is on, and two marks in one corner
|
|
||||||
// would overlap. The watched tick and the heart own the other corner.
|
|
||||||
mediaTypeMark(item)?.let { (icon, description) ->
|
|
||||||
MediaStatusIcon(
|
|
||||||
icon = icon,
|
|
||||||
description = description,
|
|
||||||
tint = Color.White.copy(alpha = 0.92f),
|
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (focused) {
|
if (focused) {
|
||||||
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||||
@@ -2147,15 +2148,37 @@ private fun MediaCard(
|
|||||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
if (showSecondaryMetadata) {
|
if (showSecondaryMetadata) {
|
||||||
Text(
|
// The media type sits *in* the metadata line rather than over the poster:
|
||||||
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
|
// it is informational, like the year and the runtime beside it, and the
|
||||||
color = QuietText,
|
// artwork is the one thing on the card worth looking at. The row is
|
||||||
fontSize = 12.sp,
|
// vertically centred and the mark is smaller than the line it sits on, so
|
||||||
fontWeight = FontWeight.Medium,
|
// it cannot make the card taller than one without it.
|
||||||
maxLines = 1,
|
val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null
|
||||||
overflow = TextOverflow.Ellipsis,
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
||||||
)
|
) {
|
||||||
|
typeMark?.let { (icon, _) ->
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
// The card's own description already names the type; a second
|
||||||
|
// reading of it here would be the same fact said twice.
|
||||||
|
contentDescription = null,
|
||||||
|
tint = EmbyGreen,
|
||||||
|
modifier = Modifier.size(MediaTypeMarkSize),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
|
||||||
|
color = QuietText,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(3.dp))
|
Spacer(Modifier.height(3.dp))
|
||||||
}
|
}
|
||||||
@@ -2395,8 +2418,12 @@ private fun cardDescription(
|
|||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
progress: Float,
|
progress: Float,
|
||||||
showWatchedEpisodeCount: Boolean,
|
showWatchedEpisodeCount: Boolean,
|
||||||
|
showMediaTypeIcon: Boolean = false,
|
||||||
): String = buildString {
|
): String = buildString {
|
||||||
append(item.name)
|
append(item.name)
|
||||||
|
if (showMediaTypeIcon) mediaTypeMark(item)?.let { (_, description) ->
|
||||||
|
append(", ").append(description)
|
||||||
|
}
|
||||||
item.seriesName?.let { append(", ").append(it) }
|
item.seriesName?.let { append(", ").append(it) }
|
||||||
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
|
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
|
||||||
if (item.userData?.played == true) append(", watched")
|
if (item.userData?.played == true) append(", watched")
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ private fun FeaturedMovieCard(
|
|||||||
val repository = ServiceLocator.repository
|
val repository = ServiceLocator.repository
|
||||||
val showLogo = repository.showTitleLogo
|
val showLogo = repository.showTitleLogo
|
||||||
val logoUrl = remember(item.id, item.imageTags, item.parentLogoItemId, item.parentLogoImageTag, showLogo) {
|
val logoUrl = remember(item.id, item.imageTags, item.parentLogoItemId, item.parentLogoImageTag, showLogo) {
|
||||||
if (showLogo) repository.logoUrl(item, 640) else null
|
if (showLogo) repository.logoUrl(item) else null
|
||||||
}
|
}
|
||||||
val useTextTitle = useTextTitleForLogo(logoUrl)
|
val useTextTitle = useTextTitleForLogo(logoUrl)
|
||||||
FocusScaleContainer(
|
FocusScaleContainer(
|
||||||
@@ -424,11 +424,10 @@ private fun FeaturedMovieCard(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
onTextLayout = { titleLines = it.lineCount },
|
onTextLayout = { titleLines = it.lineCount },
|
||||||
)
|
)
|
||||||
} else {
|
} else if (logoUrl != null) {
|
||||||
AsyncImage(
|
TitleLogoImage(
|
||||||
model = logoUrl,
|
logoUrl = logoUrl,
|
||||||
contentDescription = item.name,
|
contentDescription = item.name,
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
alignment = Alignment.CenterStart,
|
alignment = Alignment.CenterStart,
|
||||||
modifier = Modifier.width(240.dp).height(56.dp),
|
modifier = Modifier.width(240.dp).height(56.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refreshAll()
|
refreshAll()
|
||||||
|
// The Genres browser orders its rail from what this viewer watches, and reads that
|
||||||
|
// answer synchronously when the page is constructed. Warmed here because the browser
|
||||||
|
// is never the first thing a television draws — somebody has to travel the rail to
|
||||||
|
// reach it — so by the time it is opened this has long since landed, and the page
|
||||||
|
// pays nothing for being personalised. Costs one request per six hours per set.
|
||||||
|
repository.warmGenreAffinity()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Arrives as the player exits, ahead of the report and well ahead of the rows
|
// Arrives as the player exits, ahead of the report and well ahead of the rows
|
||||||
// coming back, so the card a viewer is standing on already shows the progress
|
// coming back, so the card a viewer is standing on already shows the progress
|
||||||
@@ -463,6 +469,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
|
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
|
||||||
}
|
}
|
||||||
launch { runCatching { repository.hasTrailer(item.id) } }
|
launch { runCatching { repository.hasTrailer(item.id) } }
|
||||||
|
// The logo is the one part of a detail page's hero that arrived after the page
|
||||||
|
// did: it is a second image, and it cannot be drawn until it has been judged
|
||||||
|
// legible against the near-black scrim, so until that judgement lands the page
|
||||||
|
// shows the plain-text title and swaps. Warming it here means the bytes and the
|
||||||
|
// verdict are both held by the time the press composes the page, so the title
|
||||||
|
// treatment is on its first frame. Skipped outright when the viewer has turned
|
||||||
|
// logos off, which is the one case where the fetch could never be spent.
|
||||||
|
if (repository.showTitleLogo) {
|
||||||
|
launch { warmTitleLogo(repository.logoUrl(item)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,10 @@ import androidx.tv.material3.Card
|
|||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -2356,7 +2358,7 @@ private fun HomeScreen(
|
|||||||
subjectId = item.id,
|
subjectId = item.id,
|
||||||
title = item.name,
|
title = item.name,
|
||||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
||||||
logoUrl = repo.logoUrl(item, maxWidth = 720),
|
logoUrl = repo.logoUrl(item),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -2407,14 +2409,37 @@ private fun HomeScreen(
|
|||||||
// rendered as a shelf, so this is the only thing that can still see it.
|
// rendered as a shelf, so this is the only thing that can still see it.
|
||||||
// Warm both section heroes while Home is being read. Navigation then normally draws
|
// Warm both section heroes while Home is being read. Navigation then normally draws
|
||||||
// from memory, while the gateway's user/placement cache keeps this cheap across TVs.
|
// from memory, while the gateway's user/placement cache keeps this cheap across TVs.
|
||||||
|
//
|
||||||
|
// The same effect is what makes an operator's hero change arrive on a set that is
|
||||||
|
// already switched on: the gateway announces the configuration behind the heroes as a
|
||||||
|
// revision on the status poll, so this waits on that flow rather than on a timer, and
|
||||||
|
// refetches only when it moves. Collected inside the effect rather than read as state
|
||||||
|
// in the body, because the launcher must not recompose merely to be told a number.
|
||||||
LaunchedEffect(settings.userId) {
|
LaunchedEffect(settings.userId) {
|
||||||
listOf(
|
var appliedHeroRevision: String? = null
|
||||||
BrowseDestination.MOVIES to "movies",
|
ServiceLocator.maintenance.heroRevision.collectLatest { revision ->
|
||||||
BrowseDestination.SHOWS to "tv_shows",
|
val moved = appliedHeroRevision != null &&
|
||||||
).forEach { (destination, placement) ->
|
revision.isNotBlank() && revision != appliedHeroRevision
|
||||||
launch {
|
if (revision.isNotBlank()) appliedHeroRevision = revision
|
||||||
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
|
coroutineScope {
|
||||||
sectionHeroRows = sectionHeroRows + (destination to resolved)
|
// Home's hero arrives with the home rows, so it is asked for separately
|
||||||
|
// only once the configuration behind it has actually moved. Fetching it at
|
||||||
|
// launch would replace a hero already on screen with a second composition
|
||||||
|
// of the same thing, which is a flash for nothing.
|
||||||
|
listOfNotNull(
|
||||||
|
BrowseDestination.MOVIES to "movies",
|
||||||
|
BrowseDestination.SHOWS to "tv_shows",
|
||||||
|
(BrowseDestination.HOME to "home").takeIf { moved },
|
||||||
|
).forEach { (destination, placement) ->
|
||||||
|
if (!moved && sectionHeroRows.containsKey(destination)) return@forEach
|
||||||
|
launch {
|
||||||
|
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
|
||||||
|
// Replaced one placement at a time, never cleared first: the
|
||||||
|
// cards that did not change keep the artwork already decoded
|
||||||
|
// for them and the row never blanks between the two states.
|
||||||
|
sectionHeroRows = sectionHeroRows + (destination to resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2432,7 +2457,15 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) {
|
val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) {
|
||||||
when (selectedDestination) {
|
when (selectedDestination) {
|
||||||
BrowseDestination.HOME -> selectHomeHeroMovies(rows, heroDay, homeContent.rows)
|
// The separately fetched home hero wins where there is one, which is only
|
||||||
|
// after the configuration moved under a set sitting on this screen; otherwise
|
||||||
|
// the hero row that came with the home payload is still the right answer.
|
||||||
|
BrowseDestination.HOME -> selectHomeHeroMovies(
|
||||||
|
rows,
|
||||||
|
heroDay,
|
||||||
|
sectionHeroRows[BrowseDestination.HOME]?.takeIf(List<HomeRow>::isNotEmpty)
|
||||||
|
?: homeContent.rows,
|
||||||
|
)
|
||||||
BrowseDestination.MOVIES, BrowseDestination.SHOWS ->
|
BrowseDestination.MOVIES, BrowseDestination.SHOWS ->
|
||||||
serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty())
|
serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty())
|
||||||
else -> emptyList()
|
else -> emptyList()
|
||||||
|
|||||||
@@ -1,14 +1,128 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.graphics.get
|
import androidx.core.graphics.get
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import coil.compose.AsyncImage
|
||||||
import coil.imageLoader
|
import coil.imageLoader
|
||||||
|
import coil.request.CachePolicy
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.request.SuccessResult
|
import coil.request.SuccessResult
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app knows about a title's logo artwork, remembered for the life of the
|
||||||
|
* process.
|
||||||
|
*
|
||||||
|
* A logo is asked about far more often than it changes: the launcher's hero draws one, the
|
||||||
|
* detail page it opens draws the same one, walking Back and pressing again draws it a third
|
||||||
|
* time, and the screensaver and the TV calendar draw it for the same shows. Two things used
|
||||||
|
* to be repeated on every one of those passes and both are visible to a viewer as a logo
|
||||||
|
* that flashes into a text title and back:
|
||||||
|
*
|
||||||
|
* - **The darkness verdict was recomputed.** [useTextTitleForLogo] decoded a small copy on
|
||||||
|
* every composition and started from "use the text title", so a page whose logo had
|
||||||
|
* already been judged legible still drew its name first and swapped a frame or more later.
|
||||||
|
* It is remembered here instead, keyed on the URL, so a title seen once resolves
|
||||||
|
* synchronously and the logo is on the first frame.
|
||||||
|
* - **The probe evicted the artwork it was probing.** Coil's memory cache is keyed on the
|
||||||
|
* image's URL and *not* on the size it was decoded at, so a 64×64 inspection copy written
|
||||||
|
* under that key replaced the full-size bitmap the hero had just cached — which is why a
|
||||||
|
* detail page returned to a second time decoded its logo again from disk. The probe now
|
||||||
|
* runs with the memory cache switched off, leaving the display copy alone.
|
||||||
|
*
|
||||||
|
* Invalidation is free and deliberate: an Emby logo URL carries the item id *and* the image
|
||||||
|
* tag, so artwork changed on the server is a different key here, in Coil's memory cache and
|
||||||
|
* in its disk cache alike. Nothing has to be told about it.
|
||||||
|
*/
|
||||||
|
internal object TitleLogoCache {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enough for every logo a household's launcher, calendar and screensaver could put on
|
||||||
|
* screen in a session, and small enough to be nothing next to the bitmaps themselves —
|
||||||
|
* a verdict is one boolean and a URL.
|
||||||
|
*/
|
||||||
|
private const val MAX_ENTRIES = 512
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
/** Access-ordered, so the entries that survive are the ones still being looked at. */
|
||||||
|
private val verdicts = object : LinkedHashMap<String, Boolean>(64, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>) =
|
||||||
|
size > MAX_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
private val inFlight = mutableMapOf<String, Deferred<Boolean?>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Its own scope, not the caller's, for the reason the repository's single flights have
|
||||||
|
* one: the caller here is usually a detail page's [produceState] or a focus warm, both
|
||||||
|
* of which die the moment the D-pad moves. Shared work that inherited that cancellation
|
||||||
|
* would fail everybody who had joined it.
|
||||||
|
*/
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application context, so a warm can be started from somewhere that holds no
|
||||||
|
* Android context of its own — [com.ponzischeme89.memby.ui.HomeViewModel] is the caller
|
||||||
|
* that matters, and giving a ViewModel a context to keep would be the worse trade.
|
||||||
|
* Application-scoped, so it holds nothing that could outlive its owner.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
/** Called once, from `MembyApp.onCreate`. */
|
||||||
|
fun install(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun context(): Context? = appContext
|
||||||
|
|
||||||
|
/** The remembered verdict for [url], or null if this logo has never been judged. */
|
||||||
|
fun verdict(url: String): Boolean? = synchronized(lock) { verdicts[url] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [url]'s artwork is too dark to use as a heading, judged once per URL.
|
||||||
|
*
|
||||||
|
* Null means "could not tell" — a failed or cancelled fetch — and is deliberately not
|
||||||
|
* remembered: a title must not be condemned to its text heading for the life of the
|
||||||
|
* process because the network hiccuped once.
|
||||||
|
*
|
||||||
|
* Concurrent callers for one URL share a single probe. That is not only a saving: the
|
||||||
|
* home hero and the detail page it opens ask about the same logo within a frame of each
|
||||||
|
* other, and two probes would be two decodes of the same bytes.
|
||||||
|
*/
|
||||||
|
suspend fun resolve(url: String, probe: suspend (String) -> Boolean?): Boolean? {
|
||||||
|
verdict(url)?.let { return it }
|
||||||
|
val work = synchronized(lock) {
|
||||||
|
inFlight[url] ?: scope.async {
|
||||||
|
probe(url)?.also { dark -> synchronized(lock) { verdicts[url] = dark } }
|
||||||
|
}.also { fresh ->
|
||||||
|
inFlight[url] = fresh
|
||||||
|
fresh.invokeOnCompletion { synchronized(lock) { inFlight.remove(url) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return work.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam only: a verdict cache that outlived one test would decide the next. */
|
||||||
|
internal fun reset() = synchronized(lock) {
|
||||||
|
verdicts.clear()
|
||||||
|
inFlight.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a title's logo artwork should be replaced by its plain-text name.
|
* Whether a title's logo artwork should be replaced by its plain-text name.
|
||||||
@@ -18,6 +132,9 @@ import coil.request.SuccessResult
|
|||||||
* overwhelmingly dark. Until the image has been inspected, text is the safe default — a
|
* overwhelmingly dark. Until the image has been inspected, text is the safe default — a
|
||||||
* title that arrives a frame late is better than a title that never appears.
|
* title that arrives a frame late is better than a title that never appears.
|
||||||
*
|
*
|
||||||
|
* A logo judged once is judged for the life of the process ([TitleLogoCache]), so that
|
||||||
|
* frame is paid the first time a title is seen and never again.
|
||||||
|
*
|
||||||
* Shared by the screensaver and the detail hero: both draw a title over a near-black scrim,
|
* Shared by the screensaver and the detail hero: both draw a title over a near-black scrim,
|
||||||
* and a logo that is invisible on one is invisible on the other.
|
* and a logo that is invisible on one is invisible on the other.
|
||||||
*/
|
*/
|
||||||
@@ -25,21 +142,99 @@ import coil.request.SuccessResult
|
|||||||
internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
|
internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
|
||||||
if (logoUrl == null) return true
|
if (logoUrl == null) return true
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isDark by produceState(initialValue = true, logoUrl) {
|
// Read outside the producer so a logo already judged is the *initial* value rather than
|
||||||
value = runCatching {
|
// a correction applied a frame later. This is the whole of "no flash on the way back".
|
||||||
val result = context.imageLoader.execute(
|
val known = TitleLogoCache.verdict(logoUrl)
|
||||||
ImageRequest.Builder(context)
|
val isDark by produceState(initialValue = known ?: true, logoUrl, known) {
|
||||||
.data(logoUrl)
|
if (known != null) return@produceState
|
||||||
.allowHardware(false)
|
value = TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) } ?: true
|
||||||
.size(64, 64)
|
|
||||||
.build(),
|
|
||||||
) as? SuccessResult ?: return@runCatching true
|
|
||||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
|
||||||
}.getOrDefault(true)
|
|
||||||
}
|
}
|
||||||
return isDark
|
return isDark
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The logo itself, drawn the same way everywhere it appears.
|
||||||
|
*
|
||||||
|
* Every surface asks Emby for one width ([com.ponzischeme89.memby.data.LOGO_MAX_WIDTH]), so
|
||||||
|
* the URL is identical on the launcher, the detail page, the calendar and the screensaver —
|
||||||
|
* one entry in Coil's caches, one probe, one fetch, whatever size the box happens to be.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun TitleLogoImage(
|
||||||
|
logoUrl: String,
|
||||||
|
contentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
alignment: Alignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = logoUrl,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
alignment = alignment,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a logo and judges it before anybody asks to see it.
|
||||||
|
*
|
||||||
|
* Called while a card holds focus, which is the best warning of a press this app gets: by
|
||||||
|
* the time the detail page composes, the bytes are in Coil's caches and the verdict is
|
||||||
|
* known, so the logo is on the page's first frame instead of arriving after it.
|
||||||
|
*
|
||||||
|
* It requests the artwork at [WARM_SIZE_PX] rather than at any one screen's box, because
|
||||||
|
* Coil's memory cache is keyed on the URL and validated against the requested size: a copy
|
||||||
|
* decoded at least as large as the biggest stage that draws it satisfies every smaller one,
|
||||||
|
* while a small copy would be rejected and re-decoded by the largest. Failure is silent —
|
||||||
|
* this is a hint, and every consumer still works exactly as it did without it.
|
||||||
|
*/
|
||||||
|
internal suspend fun warmTitleLogo(logoUrl: String?) {
|
||||||
|
if (logoUrl.isNullOrBlank()) return
|
||||||
|
val context = TitleLogoCache.context() ?: return
|
||||||
|
runCatching {
|
||||||
|
context.imageLoader.execute(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(logoUrl)
|
||||||
|
.size(WARM_SIZE_PX, WARM_SIZE_PX)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest logo stage in the app is the screensaver's 320×96dp; on a 1080p television
|
||||||
|
* that is 320×96 device pixels at density 1.0 and 480×144 at the 1.5 a Chromecast reports.
|
||||||
|
* Asking for a square of this side leaves [ContentScale.Fit] room for both a very wide and
|
||||||
|
* a very tall title treatment without holding a needlessly large bitmap.
|
||||||
|
*/
|
||||||
|
private const val WARM_SIZE_PX = 512
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a small copy of [url] and reports whether it is too dark to read as a heading.
|
||||||
|
*
|
||||||
|
* **The memory cache is switched off for this request and that is load-bearing.** Coil keys
|
||||||
|
* that cache on the image's URL alone, so writing this 64×64 copy under the same key as the
|
||||||
|
* displayed logo replaced it — every page that probed a logo threw away the full-size
|
||||||
|
* bitmap the last page had cached, and the next display request decoded it again. The disk
|
||||||
|
* cache is left on deliberately: the bytes are worth keeping, and they are what the display
|
||||||
|
* request reads instead of going back to the network.
|
||||||
|
*/
|
||||||
|
private suspend fun probeLogoDarkness(context: Context, url: String): Boolean? = runCatching {
|
||||||
|
val result = context.imageLoader.execute(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(url)
|
||||||
|
.allowHardware(false)
|
||||||
|
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||||
|
.size(64, 64)
|
||||||
|
.build(),
|
||||||
|
) as? SuccessResult ?: return@runCatching null
|
||||||
|
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||||
|
}.getOrElse { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
||||||
var opaquePixels = 0
|
var opaquePixels = 0
|
||||||
var darkPixels = 0
|
var darkPixels = 0
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import coil.compose.AsyncImage
|
|||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||||
|
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||||
import com.ponzischeme89.memby.ui.distinctItems
|
import com.ponzischeme89.memby.ui.distinctItems
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||||
@@ -85,7 +86,7 @@ internal fun CalendarContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
posterUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.primaryUrl(it, 360) },
|
posterUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.primaryUrl(it, 360) },
|
||||||
backdropUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.backdropUrl(it, 720) },
|
backdropUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.backdropUrl(it, 720) },
|
||||||
logoUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.logoUrl(it, 420) },
|
logoUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.logoUrl(it) },
|
||||||
) {
|
) {
|
||||||
val weeks = remember(state.calendar) { calendarAgendaWeeks(state.calendar) }
|
val weeks = remember(state.calendar) { calendarAgendaWeeks(state.calendar) }
|
||||||
val weekIndex = remember(weeks, state.selectedDate) {
|
val weekIndex = remember(weeks, state.selectedDate) {
|
||||||
@@ -589,10 +590,9 @@ private fun ProgrammeCard(
|
|||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
if (logoUrl != null) {
|
if (logoUrl != null) {
|
||||||
AsyncImage(
|
TitleLogoImage(
|
||||||
model = logoUrl,
|
logoUrl = logoUrl,
|
||||||
contentDescription = item.name,
|
contentDescription = item.name,
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier.widthIn(max = 190.dp).height(34.dp),
|
modifier = Modifier.widthIn(max = 190.dp).height(34.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -41,7 +41,20 @@ class GenreBrowseViewModel(
|
|||||||
private val repository: EmbyRepository,
|
private val repository: EmbyRepository,
|
||||||
private val itemType: String,
|
private val itemType: String,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val categories = genreCategoryTabs(itemType)
|
/**
|
||||||
|
* The rail, in this viewer's order, settled once when the screen is constructed.
|
||||||
|
*
|
||||||
|
* Read from the repository's snapshot rather than awaited, and never recomputed while
|
||||||
|
* the screen is alive. Both halves matter: the categories are the first thing the page
|
||||||
|
* draws and must not appear late, and a rail that re-ordered itself under a D-pad
|
||||||
|
* already travelling down it would be worse than one that was never personalised at all.
|
||||||
|
* A set whose affinity has not landed yet gets the catalogue's own order and is
|
||||||
|
* personalised the next time the page is opened.
|
||||||
|
*/
|
||||||
|
private val categories = personaliseGenreCategories(
|
||||||
|
genreCategoryTabs(itemType),
|
||||||
|
repository.genreAffinitySnapshot(),
|
||||||
|
)
|
||||||
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
|
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
|
||||||
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
||||||
private var pageJob: Job? = null
|
private var pageJob: Job? = null
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.genre
|
||||||
|
|
||||||
|
import com.ponzischeme89.memby.data.model.GenreAffinity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Genres browser's rail is ordered for one viewer.
|
||||||
|
*
|
||||||
|
* The catalogue itself is still product design — the same sixteen categories, the same
|
||||||
|
* labels, the same aliases — and this only decides which of them a household sees first.
|
||||||
|
* That separation is deliberate: the gateway knows *what* somebody watches, because
|
||||||
|
* Tracearr's sessions and the imported catalogue's genres are both already in Postgres, but
|
||||||
|
* it holds no copy of this list and could not be the thing that sorts it without growing
|
||||||
|
* one. So the wire carries Emby's own genre spellings with a weight each and the fold
|
||||||
|
* happens here, against the alias table this file's neighbour already owns.
|
||||||
|
*
|
||||||
|
* Nothing is ever hidden. A genre somebody has never watched is still in the rail, below
|
||||||
|
* the ones they have — which is the whole difference between personalisation and a filter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The least history worth re-ordering somebody's rail for.
|
||||||
|
*
|
||||||
|
* Below this the weights are not a taste, they are the last few things that happened to be
|
||||||
|
* on: one Western on a wet Sunday would otherwise lead the rail of a household that has
|
||||||
|
* never watched another. A viewer under the floor keeps the catalogue order and acquires
|
||||||
|
* their own as they watch, which is exactly what a new account should do.
|
||||||
|
*/
|
||||||
|
const val MIN_GENRE_AFFINITY_SESSIONS = 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share of the top genre a category must reach before it is lifted at all.
|
||||||
|
*
|
||||||
|
* The weights are already scaled so the most-watched genre is 1, so this is a proportion
|
||||||
|
* rather than a count and means the same thing for a household that watches nightly and one
|
||||||
|
* that watches on Sundays. Its job is the long tail: without it, a category holding a single
|
||||||
|
* qualifying session outranks every category holding none, and the bottom of the rail
|
||||||
|
* reshuffles on noise while the top — the half anybody looks at — says nothing new.
|
||||||
|
*/
|
||||||
|
const val GENRE_AFFINITY_FLOOR = 0.08
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scores one category against a viewer's genre weights.
|
||||||
|
*
|
||||||
|
* A category is several Emby labels (Sci-Fi & Fantasy is six), so its score is their sum:
|
||||||
|
* a household whose science fiction is catalogued by one agent as "Science Fiction" and by
|
||||||
|
* another as "Sci-Fi & Fantasy" watches one shelf, and reading the two apart would rank it
|
||||||
|
* as half of what it is. Matching is case-insensitive because the labels come from three
|
||||||
|
* metadata agents and only one of them is consistent about it.
|
||||||
|
*/
|
||||||
|
fun genreCategoryScore(category: GenreCategory, weights: Map<String, Double>): Double {
|
||||||
|
if (category.genres.isEmpty() || weights.isEmpty()) return 0.0
|
||||||
|
var total = 0.0
|
||||||
|
category.genres.forEach { label ->
|
||||||
|
total += weights[label.lowercase()] ?: 0.0
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rail, in this viewer's order.
|
||||||
|
*
|
||||||
|
* Three properties hold it together and each is pinned by a test:
|
||||||
|
*
|
||||||
|
* - **"All" never moves.** It is the catalogue itself rather than a genre, it is the first
|
||||||
|
* focus target on the page, and a rail whose first entry moved under somebody would lose
|
||||||
|
* them the one place they can always return to.
|
||||||
|
* - **The sort is stable, so the default order is the tie-break.** Every category the
|
||||||
|
* viewer has no evidence for scores zero and therefore keeps its product order, below the
|
||||||
|
* ones they watch — which is what makes an unpersonalised half of the rail still read as
|
||||||
|
* a considered list rather than as an arbitrary one.
|
||||||
|
* - **Nothing is dropped.** The output is a permutation of the input, always, whatever the
|
||||||
|
* weights say.
|
||||||
|
*
|
||||||
|
* Insufficient history returns the catalogue untouched, which is also what a gateway that
|
||||||
|
* predates the route, a household running no Tracearr, and an operator who has switched it
|
||||||
|
* off all produce.
|
||||||
|
*/
|
||||||
|
fun personaliseGenreCategories(
|
||||||
|
categories: List<GenreCategory>,
|
||||||
|
affinity: GenreAffinity,
|
||||||
|
): List<GenreCategory> {
|
||||||
|
if (categories.size < 2) return categories
|
||||||
|
if (affinity.sessions < MIN_GENRE_AFFINITY_SESSIONS) return categories
|
||||||
|
val weights = affinity.weightsByLabel()
|
||||||
|
if (weights.isEmpty()) return categories
|
||||||
|
|
||||||
|
val scores = categories.associate { category ->
|
||||||
|
val score = genreCategoryScore(category, weights)
|
||||||
|
category.id to if (score >= GENRE_AFFINITY_FLOOR) score else 0.0
|
||||||
|
}
|
||||||
|
if (scores.values.none { it > 0.0 }) return categories
|
||||||
|
|
||||||
|
// The pinned head is whatever leads the list without being a genre — the "All" entry —
|
||||||
|
// and it is taken off before the sort rather than given an unbeatable score, because a
|
||||||
|
// score is a claim about watching and this is a claim about layout.
|
||||||
|
val pinned = categories.takeWhile { it.genres.isEmpty() }
|
||||||
|
val sortable = categories.drop(pinned.size)
|
||||||
|
return pinned + sortable.sortedByDescending { scores[it.id] ?: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weights keyed for lookup, lower-cased once rather than per comparison.
|
||||||
|
*
|
||||||
|
* A repeated label keeps its largest weight rather than the last one seen: the list is the
|
||||||
|
* gateway's, ordered by weight, and a duplicate can only be a spelling that reached it
|
||||||
|
* twice — reading the smaller of the two would quietly understate the shelf.
|
||||||
|
*/
|
||||||
|
private fun GenreAffinity.weightsByLabel(): Map<String, Double> {
|
||||||
|
if (genres.isEmpty()) return emptyMap()
|
||||||
|
val out = HashMap<String, Double>(genres.size)
|
||||||
|
genres.forEach { entry ->
|
||||||
|
val key = entry.genre.trim().lowercase()
|
||||||
|
if (key.isEmpty() || entry.weight <= 0.0) return@forEach
|
||||||
|
val existing = out[key]
|
||||||
|
if (existing == null || entry.weight > existing) out[key] = entry.weight
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ class ScreensaverActivity : ComponentActivity() {
|
|||||||
TrailerPlaybackRequest(
|
TrailerPlaybackRequest(
|
||||||
subjectId = item.id,
|
subjectId = item.id,
|
||||||
title = item.name,
|
title = item.name,
|
||||||
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
|
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import com.ponzischeme89.memby.R
|
|||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||||
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
||||||
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
|
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
|
||||||
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
||||||
@@ -763,11 +764,10 @@ private fun InfoAndActions(
|
|||||||
// Prefer the item's Emby "Logo" artwork over a plain-text title, when the
|
// Prefer the item's Emby "Logo" artwork over a plain-text title, when the
|
||||||
// user has it enabled and this item actually has a logo image.
|
// user has it enabled and this item actually has a logo image.
|
||||||
val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null
|
val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null
|
||||||
if (!useTextTitleForLogo(logoUrl)) {
|
if (logoUrl != null && !useTextTitleForLogo(logoUrl)) {
|
||||||
AsyncImage(
|
TitleLogoImage(
|
||||||
model = logoUrl,
|
logoUrl = logoUrl,
|
||||||
contentDescription = item.name,
|
contentDescription = item.name,
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
alignment = Alignment.CenterStart,
|
alignment = Alignment.CenterStart,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// A fixed logo stage keeps artwork consistently sized even when
|
// A fixed logo stage keeps artwork consistently sized even when
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remembering half of the logo work, which is the half a viewer notices.
|
||||||
|
*
|
||||||
|
* The probe itself needs a decoder and a network and is not testable here; what is testable
|
||||||
|
* is that it is asked **once** per logo — a second ask is a second decode of bytes the app
|
||||||
|
* already has, and, because the verdict starts at "use the text title", a second frame of a
|
||||||
|
* page showing its name where its title treatment belongs.
|
||||||
|
*/
|
||||||
|
class TitleLogoCacheTest {
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun clearCache() = TitleLogoCache.reset()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a logo never seen has no verdict`() {
|
||||||
|
assertNull(TitleLogoCache.verdict("https://gw/v1/images/1/logo?tag=a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resolved verdict is remembered and the probe is not asked again`() = runBlocking {
|
||||||
|
val probes = AtomicInteger()
|
||||||
|
val url = "https://gw/v1/images/1/logo?tag=a"
|
||||||
|
val probe: suspend (String) -> Boolean? = { probes.incrementAndGet(); false }
|
||||||
|
|
||||||
|
assertEquals(false, TitleLogoCache.resolve(url, probe))
|
||||||
|
// The synchronous read is what lets the detail page draw the logo on its first
|
||||||
|
// frame rather than correcting itself a frame later.
|
||||||
|
assertEquals(false, TitleLogoCache.verdict(url))
|
||||||
|
assertEquals(false, TitleLogoCache.resolve(url, probe))
|
||||||
|
assertEquals(1, probes.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an image tag change is a different logo`() = runBlocking {
|
||||||
|
val probes = AtomicInteger()
|
||||||
|
val probe: suspend (String) -> Boolean? = { probes.incrementAndGet(); true }
|
||||||
|
|
||||||
|
TitleLogoCache.resolve("https://gw/v1/images/1/logo?tag=old", probe)
|
||||||
|
TitleLogoCache.resolve("https://gw/v1/images/1/logo?tag=new", probe)
|
||||||
|
|
||||||
|
// Artwork replaced on the server carries a new tag, so nothing has to invalidate
|
||||||
|
// anything: the old verdict simply stops being asked for.
|
||||||
|
assertEquals(2, probes.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `concurrent asks for one logo share a single probe`() = runBlocking {
|
||||||
|
val probes = AtomicInteger()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
val url = "https://gw/v1/images/1/logo?tag=a"
|
||||||
|
val probe: suspend (String) -> Boolean? = {
|
||||||
|
probes.incrementAndGet()
|
||||||
|
release.await()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
val first = async { TitleLogoCache.resolve(url, probe) }
|
||||||
|
val second = async { TitleLogoCache.resolve(url, probe) }
|
||||||
|
val third = async { TitleLogoCache.resolve(url, probe) }
|
||||||
|
release.complete(Unit)
|
||||||
|
|
||||||
|
assertEquals(listOf(false, false, false), listOf(first.await(), second.await(), third.await()))
|
||||||
|
// The home hero and the detail page it opens ask within a frame of each other.
|
||||||
|
assertEquals(1, probes.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a probe that could not tell is not remembered as dark`() = runBlocking {
|
||||||
|
val url = "https://gw/v1/images/1/logo?tag=a"
|
||||||
|
|
||||||
|
assertNull(TitleLogoCache.resolve(url) { null })
|
||||||
|
|
||||||
|
// A hiccup must not condemn a title to its text heading for the life of the process.
|
||||||
|
assertNull(TitleLogoCache.verdict(url))
|
||||||
|
assertEquals(false, TitleLogoCache.resolve(url) { false })
|
||||||
|
assertEquals(false, TitleLogoCache.verdict(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the cache is bounded and keeps what is still being looked at`() = runBlocking {
|
||||||
|
val kept = "https://gw/v1/images/kept/logo?tag=a"
|
||||||
|
TitleLogoCache.resolve(kept) { false }
|
||||||
|
repeat(600) { index ->
|
||||||
|
TitleLogoCache.resolve("https://gw/v1/images/$index/logo?tag=a") { true }
|
||||||
|
// Touching it keeps it, which is the whole point of an access-ordered cache:
|
||||||
|
// the logo a viewer keeps coming back to is the one worth holding.
|
||||||
|
TitleLogoCache.verdict(kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(false, TitleLogoCache.verdict(kept))
|
||||||
|
assertTrue("early entries should have been evicted", TitleLogoCache.verdict("https://gw/v1/images/0/logo?tag=a") == null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.genre
|
||||||
|
|
||||||
|
import com.ponzischeme89.memby.data.model.GenreAffinity
|
||||||
|
import com.ponzischeme89.memby.data.model.GenreWeight
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordering rule for the Genres browser's rail.
|
||||||
|
*
|
||||||
|
* What is worth pinning here is not that a well-watched genre rises — that is one line —
|
||||||
|
* but the three promises around it: nothing is ever hidden, a viewer with no history keeps
|
||||||
|
* the catalogue's order, and the unpersonalised half of the rail stays in product order
|
||||||
|
* rather than becoming arbitrary.
|
||||||
|
*/
|
||||||
|
class GenrePersonalisationTest {
|
||||||
|
|
||||||
|
private fun affinity(sessions: Int, vararg weights: Pair<String, Double>) = GenreAffinity(
|
||||||
|
sessions = sessions,
|
||||||
|
genres = weights.map { GenreWeight(it.first, it.second) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private val tabs get() = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `frequently watched genres rise to the top`() {
|
||||||
|
val ordered = personaliseGenreCategories(
|
||||||
|
tabs,
|
||||||
|
affinity(120, "Crime" to 1.0, "Drama" to 0.8, "Thriller" to 0.6, "Documentary" to 0.4),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("all", "crime", "drama", "thriller", "documentary"),
|
||||||
|
ordered.take(5).map(GenreCategory::id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue itself is the first focus target on the page and the one place a viewer
|
||||||
|
* can always return to, so it is pinned rather than sorted.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the all entry never moves`() {
|
||||||
|
val ordered = personaliseGenreCategories(tabs, affinity(200, "Western" to 1.0))
|
||||||
|
assertEquals(ALL_MEDIA_CATEGORY_ID, ordered.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personalisation, not a filter: every genre stays reachable however little it is watched. */
|
||||||
|
@Test
|
||||||
|
fun `no genre is ever dropped`() {
|
||||||
|
val ordered = personaliseGenreCategories(tabs, affinity(300, "Horror" to 1.0, "Comedy" to 0.5))
|
||||||
|
assertEquals(tabs.size, ordered.size)
|
||||||
|
assertEquals(tabs.map(GenreCategory::id).sorted(), ordered.map(GenreCategory::id).sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below the floor the weights are not a taste, they are the last few things that
|
||||||
|
* happened to be on — and a new account must look like the product, not like a rail
|
||||||
|
* built out of one wet Sunday.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `insufficient history keeps the default order`() {
|
||||||
|
val ordered = personaliseGenreCategories(
|
||||||
|
tabs,
|
||||||
|
affinity(MIN_GENRE_AFFINITY_SESSIONS - 1, "Western" to 1.0),
|
||||||
|
)
|
||||||
|
assertEquals(tabs.map(GenreCategory::id), ordered.map(GenreCategory::id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no history at all keeps the default order`() {
|
||||||
|
assertEquals(
|
||||||
|
tabs.map(GenreCategory::id),
|
||||||
|
personaliseGenreCategories(tabs, GenreAffinity()).map(GenreCategory::id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A gateway that answered with a count but no labels has said nothing about anybody. */
|
||||||
|
@Test
|
||||||
|
fun `sessions without weights keep the default order`() {
|
||||||
|
assertEquals(
|
||||||
|
tabs.map(GenreCategory::id),
|
||||||
|
personaliseGenreCategories(tabs, affinity(500)).map(GenreCategory::id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categories a viewer has no evidence for keep their product order below the ones
|
||||||
|
* they watch — the sort is stable, and that is what stops the bottom of the rail reading
|
||||||
|
* as an arbitrary list.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `unwatched genres keep their catalogue order`() {
|
||||||
|
val ordered = personaliseGenreCategories(tabs, affinity(90, "Horror" to 1.0))
|
||||||
|
val defaultTail = tabs.map(GenreCategory::id).filterNot { it == "horror" || it == "all" }
|
||||||
|
assertEquals(defaultTail, ordered.map(GenreCategory::id).filterNot { it == "horror" || it == "all" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The long tail must not reshuffle on noise: a category holding a single stray session
|
||||||
|
* has nothing to say, and lifting it above every category holding none would change the
|
||||||
|
* bottom of the rail every week while the top said nothing new.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a weight below the floor does not lift a category`() {
|
||||||
|
val ordered = personaliseGenreCategories(
|
||||||
|
tabs,
|
||||||
|
affinity(80, "Comedy" to 1.0, "Western" to GENRE_AFFINITY_FLOOR / 2),
|
||||||
|
)
|
||||||
|
val ids = ordered.map(GenreCategory::id)
|
||||||
|
assertTrue(ids.indexOf("western") > ids.indexOf("romance"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A category is several Emby labels because three metadata agents describe one shelf
|
||||||
|
* three ways, so its score is their sum — reading them apart would rank science fiction
|
||||||
|
* as a fraction of what a household actually watches.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a category sums every spelling of its genre`() {
|
||||||
|
val weights = mapOf("science fiction" to 0.4, "sci-fi & fantasy" to 0.5)
|
||||||
|
val category = genreCategory(ALL_MEDIA_ITEM_TYPE, "sci-fi-fantasy")
|
||||||
|
assertEquals(0.9, genreCategoryScore(category, weights), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matching a genre label ignores case`() {
|
||||||
|
val ordered = personaliseGenreCategories(tabs, affinity(90, "cRiMe" to 1.0))
|
||||||
|
assertEquals("crime", ordered[1].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The All entry is the catalogue, not a genre; nothing it is scored against exists. */
|
||||||
|
@Test
|
||||||
|
fun `the all entry scores nothing`() {
|
||||||
|
assertEquals(
|
||||||
|
0.0,
|
||||||
|
genreCategoryScore(genreCategory(ALL_MEDIA_ITEM_TYPE, ALL_MEDIA_CATEGORY_ID), mapOf("drama" to 1.0)),
|
||||||
|
1e-9,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A viewer's taste orders the Movies and TV Series rails as well as the mixed one. */
|
||||||
|
@Test
|
||||||
|
fun `each media type is personalised from the same reading`() {
|
||||||
|
val watching = affinity(150, "Documentary" to 1.0)
|
||||||
|
assertEquals("documentary", personaliseGenreCategories(genreCategoryTabs("Movie"), watching)[1].id)
|
||||||
|
assertEquals("documentary", personaliseGenreCategories(genreCategoryTabs("Series"), watching)[1].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -362,6 +362,16 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
|||||||
if _, ok := body["preferencesRevision"]; !ok {
|
if _, ok := body["preferencesRevision"]; !ok {
|
||||||
t.Fatalf("status response carried no preferences revision: %v", body)
|
t.Fatalf("status response carried no preferences revision: %v", body)
|
||||||
}
|
}
|
||||||
|
// Likewise present with no store behind it. This is the whole delivery channel for an
|
||||||
|
// operator's hero change: a television comparing against a missing field would go on
|
||||||
|
// drawing yesterday's hero until it was next restarted.
|
||||||
|
hero, ok := body["hero"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("status response carried no hero revision: %v", body)
|
||||||
|
}
|
||||||
|
if revision, _ := hero["revision"].(string); revision == "" {
|
||||||
|
t.Fatalf("hero revision was empty; nothing would ever be refetched: %v", hero)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// With the probe switched off there is nothing to say, and a client must not be handed a
|
// With the probe switched off there is nothing to say, and a client must not be handed a
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ type Server struct {
|
|||||||
// featurePolicy keeps the operator's feature switches out of the request path. See
|
// featurePolicy keeps the operator's feature switches out of the request path. See
|
||||||
// features_cache.go.
|
// features_cache.go.
|
||||||
featurePolicy featurePolicyCache
|
featurePolicy featurePolicyCache
|
||||||
|
// heroPolicy keeps the operator's hero choices out of the request path, which now
|
||||||
|
// includes the status poll every open television makes. See hero_revision.go.
|
||||||
|
heroPolicy heroPolicyCache
|
||||||
// upstream deduplicates concurrent cache misses for the same key, so two televisions
|
// upstream deduplicates concurrent cache misses for the same key, so two televisions
|
||||||
// asking for the same expensive answer at the same moment cost one upstream call
|
// asking for the same expensive answer at the same moment cost one upstream call
|
||||||
// rather than two. See coalesce.go.
|
// rather than two. See coalesce.go.
|
||||||
@@ -272,6 +275,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
||||||
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
||||||
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
|
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
|
||||||
|
v1.Handle("GET /v1/genres/affinity", s.authed(s.handleGenreAffinity))
|
||||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
||||||
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
|
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Genres browser's rail is a fixed catalogue in product order on the television, and
|
||||||
|
// that order is deliberately not server data — it must not jump around while home rows are
|
||||||
|
// arriving. What *is* server data is the evidence: Tracearr already knows who watched what,
|
||||||
|
// and the imported catalogue already knows each title's genres, so the gateway can say
|
||||||
|
// which labels a viewer actually watches and let the set decide what to do about it.
|
||||||
|
//
|
||||||
|
// The split is the point. The gateway sends Emby's own genre spellings with a weight each;
|
||||||
|
// the television folds them into its sixteen categories through the alias table it already
|
||||||
|
// owns and re-orders its own rail. A gateway that sorted the categories itself would need a
|
||||||
|
// second copy of that catalogue, and the two would disagree the first time a category
|
||||||
|
// gained an alias — which is the failure the aliases exist to fix in the first place.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// genreAffinityTTL is how long one viewer's reading is kept. Long, because this is
|
||||||
|
// exactly the sort of answer that must never be on the path of opening a page and
|
||||||
|
// because taste does not move in an evening — but not indefinite, so a household whose
|
||||||
|
// viewing changes sees the rail follow it within a day.
|
||||||
|
genreAffinityTTL = 6 * time.Hour
|
||||||
|
|
||||||
|
// genreAffinityEmptyTTL remembers "nothing to say about this viewer" for a shorter
|
||||||
|
// span. A new account genuinely acquires a history, and a six-hour no would leave their
|
||||||
|
// first evening of watching invisible until the following morning.
|
||||||
|
genreAffinityEmptyTTL = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// genreAffinityEntry is one label on the wire.
|
||||||
|
type genreAffinityEntry struct {
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Weight float64 `json:"weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genreAffinityResponse is what a television is told.
|
||||||
|
//
|
||||||
|
// Sessions rides along because the ordering rule on the set refuses to personalise below a
|
||||||
|
// floor, and a share of nothing is not evidence: a viewer three sessions old would
|
||||||
|
// otherwise have one of those three deciding what leads their rail. It is the count of
|
||||||
|
// qualifying sessions the weights were built from, not the household's total.
|
||||||
|
type genreAffinityResponse struct {
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
Genres []genreAffinityEntry `json:"genres"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseGenreAffinity scales the weights so the most-watched genre is 1.
|
||||||
|
//
|
||||||
|
// Pure, and the reason it exists is that the television's rule is written in shares rather
|
||||||
|
// than in counts: a household that watches every night and one that watches on Sundays must
|
||||||
|
// personalise the same way, and a floor expressed in raw sessions would mean something
|
||||||
|
// different for each of them. Anything not positive is dropped rather than sent as a zero —
|
||||||
|
// a genre with no weight is one the ordering has nothing to say about, and saying so with a
|
||||||
|
// row invites the set to treat it as a considered nil.
|
||||||
|
func normaliseGenreAffinity(affinity store.GenreAffinity) genreAffinityResponse {
|
||||||
|
out := genreAffinityResponse{Sessions: affinity.Sessions, Genres: []genreAffinityEntry{}}
|
||||||
|
top := 0.0
|
||||||
|
for _, entry := range affinity.Genres {
|
||||||
|
if entry.Score > top {
|
||||||
|
top = entry.Score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if top <= 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, entry := range affinity.Genres {
|
||||||
|
if entry.Genre == "" || entry.Score <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Genres = append(out.Genres, genreAffinityEntry{
|
||||||
|
Genre: entry.Genre,
|
||||||
|
Weight: entry.Score / top,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenreAffinity answers which genres this viewer watches.
|
||||||
|
//
|
||||||
|
// It is its own route rather than a field on /v1/home for the reason the update verdict is:
|
||||||
|
// home is cached per user and is the response every television in the house is waiting on,
|
||||||
|
// while this is asked for at most once per session by the one set whose viewer has opened
|
||||||
|
// the Genres browser. Nothing on the launcher wants it.
|
||||||
|
//
|
||||||
|
// Every way this can fail is the same answer — an empty reading, which the television reads
|
||||||
|
// as "use the default order". A rail that refused to draw because Tracearr was unreachable
|
||||||
|
// would be a personalisation feature costing somebody their genre list.
|
||||||
|
func (s *Server) handleGenreAffinity(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||||
|
ctx := r.Context()
|
||||||
|
key := cache.UserKey(sess.EmbyUserID, "genre-affinity:v1")
|
||||||
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||||
|
w.Header().Set("X-Memby-Cache", "hit")
|
||||||
|
writeRaw(w, http.StatusOK, raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := s.genreAffinityFor(ctx, sess)
|
||||||
|
body, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, genreAffinityResponse{Genres: []genreAffinityEntry{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ttl := genreAffinityTTL
|
||||||
|
if len(response.Genres) == 0 {
|
||||||
|
ttl = genreAffinityEmptyTTL
|
||||||
|
}
|
||||||
|
if err := s.cache.Set(ctx, key, body, ttl); err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("genre affinity cache write failed", "error", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Memby-Cache", "miss")
|
||||||
|
writeRaw(w, http.StatusOK, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// genreAffinityFor is the reading itself, separated from the caching so the failure stance
|
||||||
|
// is stated once: nothing below returns an error, because there is no trouble here a viewer
|
||||||
|
// could act on and the rail has a perfectly good answer without it.
|
||||||
|
func (s *Server) genreAffinityFor(ctx context.Context, sess store.Session) genreAffinityResponse {
|
||||||
|
empty := genreAffinityResponse{Genres: []genreAffinityEntry{}}
|
||||||
|
if s.store == nil {
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
// The operator's switch is honoured even though the rows are already in Postgres.
|
||||||
|
// Tracearr switched off means the household has said Memby may not read their viewing,
|
||||||
|
// and old rows are still their viewing.
|
||||||
|
if !s.integrationEnabled(ctx, integrationTracearr) {
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
identity, err := s.store.TracearrIdentity(ctx, sess.EmbyUserID)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Debug("genre affinity identity failed", "error", err)
|
||||||
|
}
|
||||||
|
// The session's own username is the fallback join, and it is the only one a viewer the
|
||||||
|
// recommendation builder has never profiled has.
|
||||||
|
username := identity.Username
|
||||||
|
if username == "" {
|
||||||
|
username = sess.Username
|
||||||
|
}
|
||||||
|
affinity, err := s.store.TracearrGenreAffinity(ctx, identity.TracearrUserID, username, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("genre affinity failed", "error", err)
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
response := normaliseGenreAffinity(affinity)
|
||||||
|
s.loggerFor(ctx).Debug("genre affinity read",
|
||||||
|
"sessions", response.Sessions, "genres", len(response.Genres))
|
||||||
|
return response
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormaliseGenreAffinityScalesToTheTopGenre(t *testing.T) {
|
||||||
|
response := normaliseGenreAffinity(store.GenreAffinity{
|
||||||
|
Sessions: 40,
|
||||||
|
Genres: []store.GenreWeight{
|
||||||
|
{Genre: "Crime", Score: 20},
|
||||||
|
{Genre: "Drama", Score: 10},
|
||||||
|
{Genre: "Western", Score: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if response.Sessions != 40 {
|
||||||
|
t.Fatalf("sessions = %d, want 40", response.Sessions)
|
||||||
|
}
|
||||||
|
if len(response.Genres) != 3 {
|
||||||
|
t.Fatalf("genres = %d, want 3", len(response.Genres))
|
||||||
|
}
|
||||||
|
if response.Genres[0].Genre != "Crime" || response.Genres[0].Weight != 1 {
|
||||||
|
t.Fatalf("top = %+v, want Crime at 1", response.Genres[0])
|
||||||
|
}
|
||||||
|
if response.Genres[1].Weight != 0.5 {
|
||||||
|
t.Fatalf("Drama = %v, want 0.5", response.Genres[1].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A share is the whole point: two households with wildly different amounts of viewing must
|
||||||
|
// produce the same ordering from the same balance of it.
|
||||||
|
func TestNormaliseGenreAffinityIsScaleFree(t *testing.T) {
|
||||||
|
light := normaliseGenreAffinity(store.GenreAffinity{Sessions: 12, Genres: []store.GenreWeight{
|
||||||
|
{Genre: "Comedy", Score: 3}, {Genre: "Horror", Score: 1},
|
||||||
|
}})
|
||||||
|
heavy := normaliseGenreAffinity(store.GenreAffinity{Sessions: 900, Genres: []store.GenreWeight{
|
||||||
|
{Genre: "Comedy", Score: 300}, {Genre: "Horror", Score: 100},
|
||||||
|
}})
|
||||||
|
for i := range light.Genres {
|
||||||
|
if light.Genres[i] != heavy.Genres[i] {
|
||||||
|
t.Fatalf("entry %d: %+v vs %+v", i, light.Genres[i], heavy.Genres[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty or nonsensical reading has to come back as an empty *list* rather than as null,
|
||||||
|
// because the television reads "no genres" as "use the default order" and a null would be
|
||||||
|
// one more shape for it to have an opinion about.
|
||||||
|
func TestNormaliseGenreAffinityRefusesToInventWeights(t *testing.T) {
|
||||||
|
for name, affinity := range map[string]store.GenreAffinity{
|
||||||
|
"nothing watched": {},
|
||||||
|
"all zero": {Sessions: 5, Genres: []store.GenreWeight{{Genre: "Drama"}}},
|
||||||
|
"negative": {Sessions: 5, Genres: []store.GenreWeight{{Genre: "Drama", Score: -2}}},
|
||||||
|
} {
|
||||||
|
response := normaliseGenreAffinity(affinity)
|
||||||
|
if response.Genres == nil {
|
||||||
|
t.Fatalf("%s: genres is nil, want an empty list", name)
|
||||||
|
}
|
||||||
|
if len(response.Genres) != 0 {
|
||||||
|
t.Fatalf("%s: genres = %+v, want none", name, response.Genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blank label cannot be matched against anything on the television, so it is dropped
|
||||||
|
// rather than sent as a row with a weight the set would have to know to ignore.
|
||||||
|
func TestNormaliseGenreAffinityDropsBlankLabels(t *testing.T) {
|
||||||
|
response := normaliseGenreAffinity(store.GenreAffinity{Sessions: 20, Genres: []store.GenreWeight{
|
||||||
|
{Genre: "Drama", Score: 4}, {Genre: "", Score: 2},
|
||||||
|
}})
|
||||||
|
if len(response.Genres) != 1 || response.Genres[0].Genre != "Drama" {
|
||||||
|
t.Fatalf("genres = %+v, want Drama alone", response.Genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -673,11 +673,10 @@ func (s *Server) heroRow(
|
|||||||
location = time.Local
|
location = time.Local
|
||||||
}
|
}
|
||||||
candidates := s.heroCandidates(ctx, rows, now)
|
candidates := s.heroCandidates(ctx, rows, now)
|
||||||
policy, err := s.store.HeroPolicy(ctx)
|
// The cached read, not a query: this document is now also read by the status poll, and
|
||||||
if err != nil {
|
// it must be the same copy the cache key was derived from a moment ago or the answer
|
||||||
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
// filed under a revision would not be the answer that revision names.
|
||||||
policy = store.HeroPolicy{}
|
policy := s.currentHeroPolicy(ctx)
|
||||||
}
|
|
||||||
placementPolicy := policy.Placement(store.HeroPlacementHome)
|
placementPolicy := policy.Placement(store.HeroPlacementHome)
|
||||||
pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs)
|
pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs)
|
||||||
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
|
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess s
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
location := s.cfg.RadarrLocation
|
location := s.heroLocation()
|
||||||
if location == nil {
|
// Keyed by the revision the status poll publishes, which already carries the rotation
|
||||||
location = time.Local
|
// slot. That is what makes an operator's change reachable immediately without dropping
|
||||||
}
|
// anything else the household has cached: the old entry is not invalidated, it is
|
||||||
key := cache.UserKey(sess.EmbyUserID, "hero:active:v1:"+placement+":"+heroRotationSlot(now, location))
|
// simply no longer named. See heroRevision.
|
||||||
|
key := cache.UserKey(sess.EmbyUserID, "hero:active:v2:"+placement+":"+
|
||||||
|
heroRevision(s.currentHeroPolicy(r.Context()), sess.EmbyUserID, now, location))
|
||||||
if raw, err := s.cache.Get(r.Context(), key); err == nil {
|
if raw, err := s.cache.Get(r.Context(), key); err == nil {
|
||||||
w.Header().Set("X-Memby-Cache", "hit")
|
w.Header().Set("X-Memby-Cache", "hit")
|
||||||
writeRaw(w, http.StatusOK, raw)
|
writeRaw(w, http.StatusOK, raw)
|
||||||
@@ -75,17 +77,10 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
|
|||||||
s.decorateItemRatings(ctx, result.Items)
|
s.decorateItemRatings(ctx, result.Items)
|
||||||
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
|
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
|
||||||
|
|
||||||
policy, err := s.store.HeroPolicy(ctx)
|
policy := s.currentHeroPolicy(ctx)
|
||||||
if err != nil {
|
|
||||||
s.loggerFor(ctx).Warn("section hero policy unavailable", "placement", placement, "error", err)
|
|
||||||
policy = store.HeroPolicy{}
|
|
||||||
}
|
|
||||||
placementPolicy := policy.Placement(placement)
|
placementPolicy := policy.Placement(placement)
|
||||||
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
|
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
|
||||||
location := s.cfg.RadarrLocation
|
location := s.heroLocation()
|
||||||
if location == nil {
|
|
||||||
location = time.Local
|
|
||||||
}
|
|
||||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
||||||
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,16 @@ func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "could not save hero policy")
|
writeError(w, http.StatusInternalServerError, "could not save hero policy")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.invalidateAllHomeCaches(r.Context())
|
// No cache sweep. The hero revision is part of the home and section-hero cache keys,
|
||||||
|
// so the entries built under the policy just replaced are already unreachable and age
|
||||||
|
// out on their own TTL; this only drops *this* instance's copy of the document, which
|
||||||
|
// is what makes the operator's own next read the answer they just saved rather than
|
||||||
|
// the one they replaced.
|
||||||
|
//
|
||||||
|
// It used to call InvalidateUser for every account in the house, which threw away
|
||||||
|
// every cached item lookup, image and row the household had in order to change four
|
||||||
|
// cards — so a hero edit made the next launcher on every set rebuild from Emby.
|
||||||
|
s.heroPolicy.invalidate()
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
|
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,16 +286,3 @@ func uniqueHeroIDs(ids []string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) invalidateAllHomeCaches(ctx context.Context) {
|
|
||||||
users, err := s.store.KnownUsers(ctx)
|
|
||||||
if err != nil {
|
|
||||||
s.loggerFor(ctx).Warn("hero cache invalidation could not list users", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, user := range users {
|
|
||||||
if err := s.cache.InvalidateUser(ctx, user.ID); err != nil {
|
|
||||||
s.loggerFor(ctx).Warn("hero cache invalidation failed", "user", user.ID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"hash/fnv"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// heroRevisionSchema is bumped when the *shape* of what a revision covers changes, so a
|
||||||
|
// gateway deployed mid-evening cannot hand a television a revision it has already acted
|
||||||
|
// on for a hero that would now resolve differently.
|
||||||
|
const heroRevisionSchema = 1
|
||||||
|
|
||||||
|
// heroPolicyTTL is how stale the cached hero policy may be, and it is the featurePolicyTTL
|
||||||
|
// figure for the featurePolicyTTL reason: the operator is the only writer, their own write
|
||||||
|
// clears this instance's copy outright, so the window is "how long until an instance that
|
||||||
|
// did not make the change notices" rather than "how long until my change takes effect".
|
||||||
|
//
|
||||||
|
// It matters more here than it did there. /v1/status carries the hero revision now, which
|
||||||
|
// means every open television would otherwise read this document from Postgres every ten
|
||||||
|
// seconds to answer a question whose answer changes when somebody presses Save.
|
||||||
|
const heroPolicyTTL = 5 * time.Second
|
||||||
|
|
||||||
|
// heroPolicyCache is the featurePolicyCache arrangement over the hero document: a stale
|
||||||
|
// read takes the lock and refreshes, and every other caller waits for that one refresh
|
||||||
|
// rather than starting its own.
|
||||||
|
type heroPolicyCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
value store.HeroPolicy
|
||||||
|
valid bool
|
||||||
|
fetched time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *heroPolicyCache) read(
|
||||||
|
ctx context.Context, load func(context.Context) store.HeroPolicy,
|
||||||
|
) store.HeroPolicy {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.valid && time.Since(c.fetched) < heroPolicyTTL {
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
c.value = load(ctx)
|
||||||
|
c.valid = true
|
||||||
|
c.fetched = time.Now()
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *heroPolicyCache) invalidate() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentHeroPolicy is read by the status poll, by /v1/home and by /v1/heroes/active, so
|
||||||
|
// it is cached rather than queried — see heroPolicyCache.
|
||||||
|
//
|
||||||
|
// Every failure degrades to an empty policy, which is the automatic hero: a launcher that
|
||||||
|
// would not compose because a pin could not be looked up is a far worse trade than one
|
||||||
|
// evening's operator choices being missed.
|
||||||
|
func (s *Server) currentHeroPolicy(ctx context.Context) store.HeroPolicy {
|
||||||
|
if s.store == nil {
|
||||||
|
return store.HeroPolicy{}
|
||||||
|
}
|
||||||
|
return s.heroPolicy.read(ctx, func(ctx context.Context) store.HeroPolicy {
|
||||||
|
policy, err := s.store.HeroPolicy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
||||||
|
return store.HeroPolicy{}
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) heroLocation() *time.Location {
|
||||||
|
if location := s.cfg.RadarrLocation; location != nil {
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
|
||||||
|
// heroRevision is what makes an operator's hero change arrive on the poll the television
|
||||||
|
// is already making, and it is deliberately the themeRevision shape rather than a counter
|
||||||
|
// in a table: what a hero resolves to changes when nobody has written anything — a
|
||||||
|
// schedule window opens, the rotation slot turns over — and neither of those is a row
|
||||||
|
// anybody updates.
|
||||||
|
//
|
||||||
|
// It is a hash of *what would be resolved* rather than of the stored document, which is
|
||||||
|
// what keeps a save that changed nothing from repainting every launcher in the house. It
|
||||||
|
// is per viewer because schedules can be, and because the rotation seed already is.
|
||||||
|
//
|
||||||
|
// The same value keys the cached answers (see handleHome and handleActiveHero), so the
|
||||||
|
// revision a television holds names exactly the entry it would be served: there is no
|
||||||
|
// state in which the poll says something moved and the fetch that follows returns what
|
||||||
|
// the set already had.
|
||||||
|
func heroRevision(policy store.HeroPolicy, userID string, now time.Time, location *time.Location) string {
|
||||||
|
digest := fnv.New64a()
|
||||||
|
write := func(part string) {
|
||||||
|
_, _ = digest.Write([]byte(part))
|
||||||
|
_, _ = digest.Write([]byte{0})
|
||||||
|
}
|
||||||
|
write(strconv.Itoa(heroRevisionSchema))
|
||||||
|
// The slot rather than the clock: the draw is stable within it, so hashing the time
|
||||||
|
// itself would move the revision on every poll and refetch four heroes a minute.
|
||||||
|
write(heroRotationSlot(now, location))
|
||||||
|
// A fixed order, never a map range: two instances disagreeing about a revision is
|
||||||
|
// indistinguishable from a change, and would refetch on alternate polls.
|
||||||
|
for _, name := range []string{store.HeroPlacementHome, store.HeroPlacementMovies, store.HeroPlacementTVShows} {
|
||||||
|
placement := policy.Placement(name)
|
||||||
|
write(name)
|
||||||
|
for _, id := range placement.PinnedItemIDs {
|
||||||
|
write(id)
|
||||||
|
}
|
||||||
|
write(placement.PrimeSubtitle)
|
||||||
|
// Only the schedules that are *in force* for this viewer right now. A schedule
|
||||||
|
// added for tomorrow evening changes nothing on screen tonight, and moving the
|
||||||
|
// revision for it would be a repaint with nothing behind it.
|
||||||
|
for _, id := range activeHeroScheduleIDs(policy.Schedules, name, userID, now, location) {
|
||||||
|
write(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(digest.Sum64(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heroStatus is the summary /v1/status carries: one opaque string, compared only for
|
||||||
|
// equality, which is all a television needs to know whether the hero it is drawing is
|
||||||
|
// still the one the gateway would compose.
|
||||||
|
func heroStatus(revision string) map[string]any {
|
||||||
|
return map[string]any{"revision": revision}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var heroRevisionZone = time.FixedZone("NZST", 12*60*60)
|
||||||
|
|
||||||
|
func heroRevisionAt(policy store.HeroPolicy, user string, at time.Time) string {
|
||||||
|
return heroRevision(policy, user, at, heroRevisionZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pinnedPolicy(placement string, ids ...string) store.HeroPolicy {
|
||||||
|
return store.HeroPolicy{Placements: map[string]store.HeroPlacementPolicy{
|
||||||
|
placement: {PinnedItemIDs: ids},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroRevisionIsStableWithinASlot(t *testing.T) {
|
||||||
|
policy := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||||
|
morning := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||||
|
if heroRevisionAt(policy, "user-1", morning) != heroRevisionAt(policy, "user-1", morning.Add(90*time.Minute)) {
|
||||||
|
t.Fatal("hero revision moved without anything changing; every poll would refetch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroRevisionMovesWhenAnOperatorEditsPins(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||||
|
before := heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1"), "user-1", now)
|
||||||
|
for name, policy := range map[string]store.HeroPolicy{
|
||||||
|
"added": pinnedPolicy(store.HeroPlacementHome, "film-1", "film-2"),
|
||||||
|
"removed": pinnedPolicy(store.HeroPlacementHome),
|
||||||
|
"replaced": pinnedPolicy(store.HeroPlacementHome, "film-2"),
|
||||||
|
} {
|
||||||
|
if heroRevisionAt(policy, "user-1", now) == before {
|
||||||
|
t.Fatalf("hero revision did not move when a pin was %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Order is the operator's decision about which card leads, so reordering is a change
|
||||||
|
// even though the same titles are pinned.
|
||||||
|
reordered := pinnedPolicy(store.HeroPlacementHome, "film-2", "film-1")
|
||||||
|
if heroRevisionAt(reordered, "user-1", now) ==
|
||||||
|
heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1", "film-2"), "user-1", now) {
|
||||||
|
t.Fatal("hero revision did not move when pins were reordered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroRevisionSeparatesPlacements(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||||
|
home := heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1"), "user-1", now)
|
||||||
|
movies := heroRevisionAt(pinnedPolicy(store.HeroPlacementMovies, "film-1"), "user-1", now)
|
||||||
|
if home == movies {
|
||||||
|
t.Fatal("pinning a title to Movies read as the same policy as pinning it to Home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroRevisionIgnoresACosmeticSave(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||||
|
saved := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||||
|
// UpdatedAt moves on every write. Hashing the document rather than what it resolves to
|
||||||
|
// would repaint every launcher in the house for a save that changed nothing.
|
||||||
|
resaved := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||||
|
resaved.UpdatedAt = now
|
||||||
|
if heroRevisionAt(saved, "user-1", now) != heroRevisionAt(resaved, "user-1", now) {
|
||||||
|
t.Fatal("hero revision moved for a save that changed nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroRevisionMovesWhenAScheduleOpensAndCloses(t *testing.T) {
|
||||||
|
policy := store.HeroPolicy{Schedules: []store.HeroSchedule{{
|
||||||
|
ID: "s1", ItemID: "film-9", Enabled: true,
|
||||||
|
Frequency: "daily", StartTime: "20:00", EndTime: "22:00",
|
||||||
|
Placements: []string{store.HeroPlacementHome},
|
||||||
|
}}}
|
||||||
|
before := time.Date(2026, 8, 20, 19, 0, 0, 0, heroRevisionZone)
|
||||||
|
during := time.Date(2026, 8, 20, 21, 0, 0, 0, heroRevisionZone)
|
||||||
|
// Two readings within one rotation slot, so only the schedule can be the difference.
|
||||||
|
if heroRotationSlot(before, heroRevisionZone) != heroRotationSlot(during, heroRevisionZone) {
|
||||||
|
t.Fatal("test times straddle a rotation slot; the schedule is no longer the only variable")
|
||||||
|
}
|
||||||
|
if heroRevisionAt(policy, "user-1", before) == heroRevisionAt(policy, "user-1", during) {
|
||||||
|
t.Fatal("hero revision did not move when a scheduled hero came into force")
|
||||||
|
}
|
||||||
|
empty := store.HeroPolicy{}
|
||||||
|
if heroRevisionAt(policy, "user-1", before) != heroRevisionAt(empty, "user-1", before) {
|
||||||
|
t.Fatal("a schedule that is not yet in force changed the revision; the launcher would repaint for nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroRevisionIsPerViewerForAPersonalSchedule(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 21, 0, 0, 0, heroRevisionZone)
|
||||||
|
policy := store.HeroPolicy{Schedules: []store.HeroSchedule{{
|
||||||
|
ID: "s1", ItemID: "film-9", Enabled: true, UserID: "user-1",
|
||||||
|
Frequency: "daily", StartTime: "20:00", EndTime: "22:00",
|
||||||
|
Placements: []string{store.HeroPlacementHome},
|
||||||
|
}}}
|
||||||
|
if heroRevisionAt(policy, "user-1", now) == heroRevisionAt(policy, "user-2", now) {
|
||||||
|
t.Fatal("one viewer's scheduled hero moved another viewer's revision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroRevisionMovesWithTheRotationSlot(t *testing.T) {
|
||||||
|
policy := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||||
|
morning := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||||
|
evening := time.Date(2026, 8, 20, 20, 0, 0, 0, heroRevisionZone)
|
||||||
|
if heroRevisionAt(policy, "user-1", morning) == heroRevisionAt(policy, "user-1", evening) {
|
||||||
|
t.Fatal("hero revision did not move across rotation slots; the day's draw would never be refetched")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,11 +75,18 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
||||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
||||||
hero := supportsHomeHero(r)
|
hero := supportsHomeHero(r)
|
||||||
|
now := time.Now()
|
||||||
|
// The hero revision is part of the key rather than something to invalidate. An
|
||||||
|
// operator's change therefore makes the entries built under the old policy simply
|
||||||
|
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
|
||||||
|
// every cached answer this household had, artwork and item lookups included, to change
|
||||||
|
// four cards. See heroRevision.
|
||||||
|
heroRev := heroRevision(s.currentHeroPolicy(ctx), sess.EmbyUserID, now, s.heroLocation())
|
||||||
key := cache.UserKey(
|
key := cache.UserKey(
|
||||||
sess.EmbyUserID,
|
sess.EmbyUserID,
|
||||||
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||||
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
|
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
|
||||||
":d"+sess.DeviceID,
|
":hr"+heroRev+":d"+sess.DeviceID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||||
@@ -331,7 +338,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
// does draw it put it somewhere sensible.
|
// does draw it put it somewhere sensible.
|
||||||
if hero {
|
if hero {
|
||||||
compose := timing.Start(ctx, timing.StageHero)
|
compose := timing.Start(ctx, timing.StageHero)
|
||||||
row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, time.Now())
|
row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, now)
|
||||||
compose()
|
compose()
|
||||||
if row != nil {
|
if row != nil {
|
||||||
out.Rows = append([]recommend.Row{*row}, out.Rows...)
|
out.Rows = append([]recommend.Row{*row}, out.Rows...)
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
|||||||
// season has to reach a set that is already switched on, without anybody doing
|
// season has to reach a set that is already switched on, without anybody doing
|
||||||
// anything.
|
// anything.
|
||||||
"theme": themeStatus(s.themeFor(r.Context(), sess)),
|
"theme": themeStatus(s.themeFor(r.Context(), sess)),
|
||||||
|
// The hero, as one opaque revision rather than the cards themselves — the theme
|
||||||
|
// precedent, for the same reason. A television refetches its heroes only when this
|
||||||
|
// moves, which is what turns an operator pinning a title into a change that arrives
|
||||||
|
// on the poll the set is already making rather than at the next cold start. It also
|
||||||
|
// keys the cached answers, so a revision that has moved always names a fresh build.
|
||||||
|
"hero": heroStatus(heroRevision(
|
||||||
|
s.currentHeroPolicy(r.Context()), sess.EmbyUserID, time.Now(), s.heroLocation(),
|
||||||
|
)),
|
||||||
// Whether this viewer may ask the household for titles. Per person rather than per
|
// Whether this viewer may ask the household for titles. Per person rather than per
|
||||||
// household, so it cannot ride the feature map beside it: the allowlist is the
|
// household, so it cannot ride the feature map beside it: the allowlist is the
|
||||||
// operator's decision about one account, and every television is polling this
|
// operator's decision about one account, and every television is polling this
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.60
|
0.1.61
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenreWeight is one genre label and how much of this viewer's watching it accounts for.
|
||||||
|
//
|
||||||
|
// The label is Emby's own spelling, verbatim, because the television is what turns labels
|
||||||
|
// into the categories it draws — "Science Fiction", "Sci-Fi" and "Sci-Fi & Fantasy" are one
|
||||||
|
// shelf there and three different rows here. Folding them on this side would mean the
|
||||||
|
// gateway holding a second copy of a catalogue that is deliberately product design on the
|
||||||
|
// set, and the two would drift the first time a category gained an alias.
|
||||||
|
type GenreWeight struct {
|
||||||
|
Genre string
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenreAffinity is everything one reading of a viewer's history came to: the weighted
|
||||||
|
// genres and how many sessions were behind them.
|
||||||
|
//
|
||||||
|
// Sessions is carried because it is the only thing that separates "this household watches
|
||||||
|
// Westerns" from "somebody put a Western on once" — the television refuses to personalise
|
||||||
|
// below a floor, and a share of a tiny total is not evidence.
|
||||||
|
type GenreAffinity struct {
|
||||||
|
Genres []GenreWeight
|
||||||
|
Sessions int
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// genreAffinityWindow is how far back a session still counts for. Long enough that a
|
||||||
|
// household which watches a few evenings a week has something to say, short enough that
|
||||||
|
// the crime phase somebody went through two years ago is no longer shaping their rail.
|
||||||
|
genreAffinityWindow = 180 * 24 * time.Hour
|
||||||
|
|
||||||
|
// The recency bands. Recent viewing should count for more, and three flat bands are the
|
||||||
|
// whole of "lightly weight" — an exponential decay would need a half-life nobody could
|
||||||
|
// defend and would make the answer move between two readings taken the same evening.
|
||||||
|
genreAffinityRecentWindow = 30 * 24 * time.Hour
|
||||||
|
genreAffinityMidWindow = 90 * 24 * time.Hour
|
||||||
|
|
||||||
|
// genreAffinityLimit caps the labels returned. A real library has a few dozen distinct
|
||||||
|
// genre strings and the television folds them into sixteen categories; the tail past
|
||||||
|
// this cannot change an ordering.
|
||||||
|
genreAffinityLimit = 60
|
||||||
|
|
||||||
|
// genreAffinityEngagement is the least of a title somebody must have reached before it
|
||||||
|
// says anything about their taste. A session that stopped four minutes in is evidence
|
||||||
|
// they did *not* want it, and counting those is how a rail comes to lead with the genre
|
||||||
|
// somebody keeps abandoning.
|
||||||
|
genreAffinityEngagement = 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
// TracearrGenreAffinity weighs the genres one viewer actually watches.
|
||||||
|
//
|
||||||
|
// Nothing here is stored: Tracearr's sessions are already in Postgres and the imported
|
||||||
|
// catalogue already holds each title's genres as an indexed array, so this is a join over
|
||||||
|
// two tables the gateway keeps for other reasons. A per-user genre table would be a copy of
|
||||||
|
// both, wrong the moment either changed, and would need its own reconciliation to stay
|
||||||
|
// honest — the same trade watchedMsExpr makes for watch time.
|
||||||
|
//
|
||||||
|
// An episode is credited to its *series'* genres, which is what emby_series_id is for: an
|
||||||
|
// episode row in the catalogue inherits them anyway, and a household that watches one crime
|
||||||
|
// drama nightly should read as watching crime rather than as watching nothing identifiable.
|
||||||
|
//
|
||||||
|
// The identity is matched two ways for the reason attributeWatchTime does it — the username
|
||||||
|
// is what Tracearr and Emby genuinely share, and the recorded Tracearr id is what the
|
||||||
|
// recommendation builder actually matched, so a viewer renamed in one system keeps their
|
||||||
|
// history rather than silently reading as new.
|
||||||
|
func (s *Store) TracearrGenreAffinity(
|
||||||
|
ctx context.Context,
|
||||||
|
tracearrUserID, username string,
|
||||||
|
now time.Time,
|
||||||
|
) (GenreAffinity, error) {
|
||||||
|
id := strings.TrimSpace(tracearrUserID)
|
||||||
|
name := strings.ToLower(strings.TrimSpace(username))
|
||||||
|
if id == "" && name == "" {
|
||||||
|
return GenreAffinity{}, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
WITH viewed AS (
|
||||||
|
SELECT coalesce(nullif(emby_series_id, ''), emby_item_id) AS item_id,
|
||||||
|
CASE
|
||||||
|
WHEN started_at >= $5 THEN 1.0
|
||||||
|
WHEN started_at >= $4 THEN 0.6
|
||||||
|
ELSE 0.3
|
||||||
|
END AS weight
|
||||||
|
FROM tracearr_sessions
|
||||||
|
WHERE started_at >= $3
|
||||||
|
AND (($1::text <> '' AND tracearr_user_id = $1::text)
|
||||||
|
OR ($2::text <> '' AND lower(username) = $2::text))
|
||||||
|
AND (watched OR (
|
||||||
|
total_duration_ms > 0
|
||||||
|
AND progress_ms::float8 / total_duration_ms >= $6::float8
|
||||||
|
))
|
||||||
|
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
|
||||||
|
),
|
||||||
|
joined AS (
|
||||||
|
SELECT viewed.weight, library_items.genres
|
||||||
|
FROM viewed
|
||||||
|
JOIN library_items ON library_items.id = viewed.item_id
|
||||||
|
WHERE cardinality(library_items.genres) > 0
|
||||||
|
),
|
||||||
|
scored AS (
|
||||||
|
SELECT btrim(label) AS genre, sum(joined.weight)::float8 AS score
|
||||||
|
FROM joined CROSS JOIN LATERAL unnest(joined.genres) AS label
|
||||||
|
WHERE btrim(label) <> ''
|
||||||
|
GROUP BY btrim(label)
|
||||||
|
)
|
||||||
|
SELECT scored.genre, scored.score, (SELECT count(*) FROM joined)::int
|
||||||
|
FROM scored
|
||||||
|
ORDER BY scored.score DESC, scored.genre
|
||||||
|
LIMIT `+strconv.Itoa(genreAffinityLimit),
|
||||||
|
id, name,
|
||||||
|
now.Add(-genreAffinityWindow),
|
||||||
|
now.Add(-genreAffinityMidWindow),
|
||||||
|
now.Add(-genreAffinityRecentWindow),
|
||||||
|
genreAffinityEngagement,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return GenreAffinity{}, fmt.Errorf("store: tracearr genre affinity: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := GenreAffinity{Genres: []GenreWeight{}}
|
||||||
|
for rows.Next() {
|
||||||
|
var weight GenreWeight
|
||||||
|
var sessions int
|
||||||
|
if err := rows.Scan(&weight.Genre, &weight.Score, &sessions); err != nil {
|
||||||
|
return GenreAffinity{}, fmt.Errorf("store: scan tracearr genre affinity: %w", err)
|
||||||
|
}
|
||||||
|
out.Genres = append(out.Genres, weight)
|
||||||
|
out.Sessions = sessions
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Watch time is read out of tracearr_sessions rather than stored again.
|
// Watch time is read out of tracearr_sessions rather than stored again.
|
||||||
@@ -178,3 +181,24 @@ func (s *Store) TracearrIdentities(ctx context.Context) (map[string]Recommendati
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TracearrIdentity is one Emby account's recorded Tracearr identity, or the zero value for
|
||||||
|
// somebody the recommendation builder has never profiled.
|
||||||
|
//
|
||||||
|
// The map above is what the console needs — it draws the whole household in one pass — and
|
||||||
|
// this is what a single request needs. Absence is not an error: a viewer with no profile
|
||||||
|
// row is matched by username alone, which is the join the two systems genuinely share.
|
||||||
|
func (s *Store) TracearrIdentity(ctx context.Context, embyUserID string) (RecommendationIdentity, error) {
|
||||||
|
var identity RecommendationIdentity
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT tracearr_user_id, tracearr_username
|
||||||
|
FROM recommendation_user_profiles
|
||||||
|
WHERE emby_user_id = $1`, embyUserID).Scan(&identity.TracearrUserID, &identity.Username)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return RecommendationIdentity{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RecommendationIdentity{}, fmt.Errorf("store: tracearr identity: %w", err)
|
||||||
|
}
|
||||||
|
return identity, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user