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
|
||||
- Removed: Genres row from the TV Series and Movies pages. Replaced by Genres page.
|
||||
- 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
|
||||
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.
|
||||
- **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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
**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.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -63,7 +63,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.80"
|
||||
val defaultVersionName = "0.2.81"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -7,6 +7,7 @@ import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||
import com.ponzischeme89.memby.performance.StartupTrace
|
||||
import com.ponzischeme89.memby.ui.TitleLogoCache
|
||||
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -51,6 +52,9 @@ class MembyApp : Application() {
|
||||
.crossfade(false)
|
||||
.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)
|
||||
// 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.
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||
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.GatewayFlagRequest
|
||||
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)
|
||||
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)
|
||||
|
||||
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
|
||||
@@ -471,6 +494,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
settings.ensureDeviceId()
|
||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||
|
||||
@@ -563,6 +587,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
|
||||
suspend fun signOut() {
|
||||
@@ -578,6 +603,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
|
||||
suspend fun switchProfile(profile: EmbyProfile) {
|
||||
@@ -588,6 +614,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
|
||||
suspend fun removeProfile(profile: EmbyProfile) {
|
||||
@@ -605,6 +632,8 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,6 +939,89 @@ class EmbyRepository internal constructor(
|
||||
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.
|
||||
*
|
||||
@@ -3135,7 +3247,7 @@ class EmbyRepository internal constructor(
|
||||
* or null when the item has no logo. Used to show artwork in place of the
|
||||
* 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 {
|
||||
item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo")
|
||||
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. */
|
||||
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_TTL_MS = 5L * 60L * 1_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. */
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
|
||||
|
||||
private val _preferencesRevision = MutableStateFlow(0L)
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
private val _heroRevision = MutableStateFlow("")
|
||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||
private val _genreBrowserEnabled = MutableStateFlow(false)
|
||||
private val _tvCalendarEnabled = MutableStateFlow(false)
|
||||
@@ -107,6 +108,19 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
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
|
||||
* how an operator's push reaches a television: the number changes, [PreferencesSync]
|
||||
@@ -242,6 +256,7 @@ class MaintenanceMonitor(
|
||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
@@ -275,6 +290,7 @@ class MaintenanceMonitor(
|
||||
}
|
||||
_preferencesRevision.value = status.preferencesRevision
|
||||
_theme.value = status.theme
|
||||
_heroRevision.value = status.hero.revision
|
||||
_installPermissionPrompt.value =
|
||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
||||
@@ -314,6 +330,7 @@ class MaintenanceMonitor(
|
||||
_embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.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.
|
||||
*/
|
||||
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
|
||||
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. */
|
||||
@Serializable
|
||||
data class GatewayThemeStatus(
|
||||
@@ -540,6 +563,40 @@ data class GatewayGenrePage(
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -90,6 +90,16 @@ interface GatewayApi {
|
||||
@Query("type") itemType: String? = null,
|
||||
): 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. */
|
||||
@GET("v1/library/items")
|
||||
suspend fun libraryItems(
|
||||
|
||||
@@ -121,7 +121,7 @@ class MembyDreamService : DreamService() {
|
||||
TrailerPlaybackRequest(
|
||||
subjectId = item.id,
|
||||
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)
|
||||
|
||||
@@ -617,7 +617,7 @@ private fun DetailHero(
|
||||
// treatment on this near-black scrim is an invisible heading.
|
||||
val repository = ServiceLocator.repository
|
||||
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 hasRatings = remember(ratings, showRatingsStrip) {
|
||||
@@ -964,10 +964,9 @@ private fun DetailIdentity(
|
||||
contentAlignment = Alignment.BottomStart,
|
||||
) {
|
||||
if (logo != null) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
TitleLogoImage(
|
||||
logoUrl = logo,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.BottomStart,
|
||||
modifier = Modifier
|
||||
.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.
|
||||
*
|
||||
* 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*
|
||||
* rather than a word because it sits over artwork at three metres, where "TV SHOW" in a
|
||||
* pill would be read before the poster it is covering.
|
||||
* rather than a word because the metadata line is one line at three metres, and "TV SHOW"
|
||||
* 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
|
||||
* separating films from shows is not asking about the difference between them. Anything
|
||||
@@ -2012,7 +2026,7 @@ private fun MediaCard(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount),
|
||||
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount, showMediaTypeIcon),
|
||||
modifier = modifier.width(width),
|
||||
) { focused ->
|
||||
Column {
|
||||
@@ -2119,19 +2133,6 @@ private fun MediaCard(
|
||||
airingLabel,
|
||||
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) {
|
||||
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||
@@ -2147,15 +2148,37 @@ private fun MediaCard(
|
||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||
)
|
||||
if (showSecondaryMetadata) {
|
||||
Text(
|
||||
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// The media type sits *in* the metadata line rather than over the poster:
|
||||
// it is informational, like the year and the runtime beside it, and the
|
||||
// artwork is the one thing on the card worth looking at. The row is
|
||||
// vertically centred and the mark is smaller than the line it sits on, so
|
||||
// it cannot make the card taller than one without it.
|
||||
val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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 {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
}
|
||||
@@ -2395,8 +2418,12 @@ private fun cardDescription(
|
||||
item: BaseItem,
|
||||
progress: Float,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
showMediaTypeIcon: Boolean = false,
|
||||
): String = buildString {
|
||||
append(item.name)
|
||||
if (showMediaTypeIcon) mediaTypeMark(item)?.let { (_, description) ->
|
||||
append(", ").append(description)
|
||||
}
|
||||
item.seriesName?.let { append(", ").append(it) }
|
||||
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
|
||||
if (item.userData?.played == true) append(", watched")
|
||||
|
||||
@@ -367,7 +367,7 @@ private fun FeaturedMovieCard(
|
||||
val repository = ServiceLocator.repository
|
||||
val showLogo = repository.showTitleLogo
|
||||
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)
|
||||
FocusScaleContainer(
|
||||
@@ -424,11 +424,10 @@ private fun FeaturedMovieCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = { titleLines = it.lineCount },
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
} else if (logoUrl != null) {
|
||||
TitleLogoImage(
|
||||
logoUrl = logoUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterStart,
|
||||
modifier = Modifier.width(240.dp).height(56.dp),
|
||||
)
|
||||
|
||||
@@ -183,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
init {
|
||||
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 {
|
||||
// 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
|
||||
@@ -463,6 +469,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
|
||||
}
|
||||
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.Text
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -2356,7 +2358,7 @@ private fun HomeScreen(
|
||||
subjectId = item.id,
|
||||
title = item.name,
|
||||
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.
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
listOf(
|
||||
BrowseDestination.MOVIES to "movies",
|
||||
BrowseDestination.SHOWS to "tv_shows",
|
||||
).forEach { (destination, placement) ->
|
||||
launch {
|
||||
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
|
||||
sectionHeroRows = sectionHeroRows + (destination to resolved)
|
||||
var appliedHeroRevision: String? = null
|
||||
ServiceLocator.maintenance.heroRevision.collectLatest { revision ->
|
||||
val moved = appliedHeroRevision != null &&
|
||||
revision.isNotBlank() && revision != appliedHeroRevision
|
||||
if (revision.isNotBlank()) appliedHeroRevision = revision
|
||||
coroutineScope {
|
||||
// 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) {
|
||||
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 ->
|
||||
serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty())
|
||||
else -> emptyList()
|
||||
|
||||
@@ -1,14 +1,128 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.core.graphics.get
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.compose.AsyncImage
|
||||
import coil.imageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
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.
|
||||
@@ -18,6 +132,9 @@ import coil.request.SuccessResult
|
||||
* 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.
|
||||
*
|
||||
* 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,
|
||||
* 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 {
|
||||
if (logoUrl == null) return true
|
||||
val context = LocalContext.current
|
||||
val isDark by produceState(initialValue = true, logoUrl) {
|
||||
value = runCatching {
|
||||
val result = context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(logoUrl)
|
||||
.allowHardware(false)
|
||||
.size(64, 64)
|
||||
.build(),
|
||||
) as? SuccessResult ?: return@runCatching true
|
||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||
}.getOrDefault(true)
|
||||
// Read outside the producer so a logo already judged is the *initial* value rather than
|
||||
// a correction applied a frame later. This is the whole of "no flash on the way back".
|
||||
val known = TitleLogoCache.verdict(logoUrl)
|
||||
val isDark by produceState(initialValue = known ?: true, logoUrl, known) {
|
||||
if (known != null) return@produceState
|
||||
value = TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) } ?: true
|
||||
}
|
||||
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 {
|
||||
var opaquePixels = 0
|
||||
var darkPixels = 0
|
||||
|
||||
@@ -53,6 +53,7 @@ import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||
import com.ponzischeme89.memby.ui.distinctItems
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
@@ -85,7 +86,7 @@ internal fun CalendarContent(
|
||||
modifier: Modifier = Modifier,
|
||||
posterUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.primaryUrl(it, 360) },
|
||||
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 weekIndex = remember(weeks, state.selectedDate) {
|
||||
@@ -589,10 +590,9 @@ private fun ProgrammeCard(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (logoUrl != null) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
TitleLogoImage(
|
||||
logoUrl = logoUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.widthIn(max = 190.dp).height(34.dp),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -41,7 +41,20 @@ class GenreBrowseViewModel(
|
||||
private val repository: EmbyRepository,
|
||||
private val itemType: String,
|
||||
) : 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))
|
||||
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
||||
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(
|
||||
subjectId = item.id,
|
||||
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.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
||||
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
|
||||
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
|
||||
// user has it enabled and this item actually has a logo image.
|
||||
val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null
|
||||
if (!useTextTitleForLogo(logoUrl)) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
if (logoUrl != null && !useTextTitleForLogo(logoUrl)) {
|
||||
TitleLogoImage(
|
||||
logoUrl = logoUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
// 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 {
|
||||
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
|
||||
|
||||
@@ -111,6 +111,9 @@ type Server struct {
|
||||
// featurePolicy keeps the operator's feature switches out of the request path. See
|
||||
// features_cache.go.
|
||||
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
|
||||
// asking for the same expensive answer at the same moment cost one upstream call
|
||||
// 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.
|
||||
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/genres/affinity", s.authed(s.handleGenreAffinity))
|
||||
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", 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
|
||||
}
|
||||
candidates := s.heroCandidates(ctx, rows, now)
|
||||
policy, err := s.store.HeroPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
||||
policy = store.HeroPolicy{}
|
||||
}
|
||||
// The cached read, not a query: this document is now also read by the status poll, and
|
||||
// it must be the same copy the cache key was derived from a moment ago or the answer
|
||||
// filed under a revision would not be the answer that revision names.
|
||||
policy := s.currentHeroPolicy(ctx)
|
||||
placementPolicy := policy.Placement(store.HeroPlacementHome)
|
||||
pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs)
|
||||
// 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
|
||||
}
|
||||
now := time.Now()
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "hero:active:v1:"+placement+":"+heroRotationSlot(now, location))
|
||||
location := s.heroLocation()
|
||||
// Keyed by the revision the status poll publishes, which already carries the rotation
|
||||
// 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
|
||||
// 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 {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
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)
|
||||
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
|
||||
|
||||
policy, err := s.store.HeroPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("section hero policy unavailable", "placement", placement, "error", err)
|
||||
policy = store.HeroPolicy{}
|
||||
}
|
||||
policy := s.currentHeroPolicy(ctx)
|
||||
placementPolicy := policy.Placement(placement)
|
||||
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
location := s.heroLocation()
|
||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
||||
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")
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -277,16 +286,3 @@ func uniqueHeroIDs(ids []string) []string {
|
||||
}
|
||||
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)
|
||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(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(
|
||||
sess.EmbyUserID,
|
||||
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||
":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 {
|
||||
@@ -331,7 +338,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
// does draw it put it somewhere sensible.
|
||||
if hero {
|
||||
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()
|
||||
if row != nil {
|
||||
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
|
||||
// anything.
|
||||
"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
|
||||
// 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
|
||||
|
||||
@@ -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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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