This commit is contained in:
ponzischeme89
2026-08-23 09:40:45 +12:00
parent 9a1f44da46
commit 766cea2199
19 changed files with 828 additions and 164 deletions
+8
View File
@@ -3338,6 +3338,14 @@ because a show's synopsis is already in the hero and what a show is *for* is its
cached in the repository, so warming a detail page on D-pad focus and then opening it costs cached in the repository, so warming a detail page on D-pad focus and then opening it costs
one request rather than two. The pane itself is the second half of that — while the hero is one request rather than two. The pane itself is the second half of that — while the hero is
whole the pane is 34dp, so a grid composes one row until the viewer actually goes there. whole the pane is 34dp, so a grid composes one row until the viewer actually goes there.
- **The open page owns its full item record, keyed by item id.** `HomeViewModel.focusedItem`
remains launcher state: Search can legitimately put its lightweight row object back there
when focus returns behind an overlay. `FocusedDetailsOverlay` therefore holds the raw
`getItemDetails` answer separately and merges only live user state from a matching focused
item. The bounded metadata cache also stores that raw answer rather than a row/detail merge;
a record with no `Overview` field is incomplete and joins or starts the shared detail load
instead of suppressing it. This is why Back → Open can reuse a complete answer without a
Search card permanently erasing the description.
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer, - `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer,
extras) and delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so extras) and delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so
they can be screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by they can be screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.05" val defaultVersionName = "0.3.06"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -32,12 +32,12 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CornerRadius
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Offset
import androidx.compose.ui.graphics.Size
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -342,7 +342,18 @@ internal fun FocusedDetailsOverlay(
airingNotice: AiringNotice? = null, airingNotice: AiringNotice? = null,
) { ) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
val item = focusedItem?.takeIf { it.id == selected.id } ?: selected // Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
// still consulted for live user state, but it cannot replace the full record with the
// lightweight Search card when focus returns behind the overlay.
var detailMetadata by remember(selected.id) {
mutableStateOf(homeViewModel.detailMetadataSnapshot(selected.id))
}
LaunchedEffect(selected.id, selected.isRadarrOnly, selected.isSchedule) {
if (!selected.isRadarrOnly && !selected.isSchedule) {
homeViewModel.loadDetailMetadata(selected.id)?.let { detailMetadata = it }
}
}
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
if (item.isRadarrOnly) { if (item.isRadarrOnly) {
// A film Radarr is tracking that Emby has never imported. It is the one card with a // A film Radarr is tracking that Emby has never imported. It is the one card with a
// page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why // page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why
@@ -402,6 +413,20 @@ internal fun FocusedDetailsOverlay(
} }
} }
/**
* Combines the three owners involved in a detail page without confusing their lifetimes:
* the selected route fixes identity, current focus contributes live user state only when
* it is still the same item, and the full detail response supplies descriptive metadata.
*/
internal fun detailItemForRoute(
selected: BaseItem,
focused: BaseItem?,
detailMetadata: BaseItem?,
): BaseItem = focusedItemWithMetadata(
item = focused?.takeIf { it.id == selected.id } ?: selected,
metadata = detailMetadata,
)
@Composable @Composable
internal fun FocusedQuickActionsOverlay( internal fun FocusedQuickActionsOverlay(
homeViewModel: HomeViewModel, homeViewModel: HomeViewModel,
@@ -17,8 +17,11 @@ import com.ponzischeme89.memby.data.millisecondsToTicks
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -173,6 +176,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) { private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
} }
private val metadataInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
/** Row engagement, buffered here and uploaded in batches. */ /** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics() private val analytics = RowAnalytics()
@@ -417,12 +421,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
detailPrefetchJob?.cancel() detailPrefetchJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) { metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS) delay(FOCUS_METADATA_DEBOUNCE_MS)
if (cached == null && !item.isSchedule) { if (!item.isSchedule) {
val details = runCatching { val details = loadDetailMetadata(item.id) ?: return@launch
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
val taggedDetails = focusedItemWithMetadata(item, details) val taggedDetails = focusedItemWithMetadata(item, details)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) { if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails _focusedItem.value = taggedDetails
} }
@@ -631,6 +632,57 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
} }
/**
* The already-loaded detail record for [itemId], without any row or route state folded
* into it. A detail overlay uses this for its opening frame, then [loadDetailMetadata]
* joins an existing request or starts the missing one.
*/
internal fun detailMetadataSnapshot(itemId: String): BaseItem? =
synchronized(metadataCache) { metadataCache[itemId] }
/**
* Loads one full item record, shared by focus prefetch and the detail overlay.
*
* The cache owns only the repository's detail response. Search cards, home rows and
* recommendation routes are presentation inputs and are merged at the reader; caching
* one of those merged objects lets a lightweight Search result overwrite the state a
* reopened detail page relies on. The request lives in [viewModelScope], so losing
* focus or closing the first page does not cancel work a reopened page is awaiting.
*/
internal suspend fun loadDetailMetadata(itemId: String): BaseItem? {
if (itemId.isBlank()) return null
var cached: BaseItem? = null
val request = synchronized(metadataCache) {
cached = metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }
if (cached != null) {
null
} else {
metadataInFlight[itemId] ?: newDetailMetadataRequest(itemId)
}
}
return cached ?: request?.await()
}
private fun newDetailMetadataRequest(itemId: String): Deferred<BaseItem?> {
val request = viewModelScope.async(
context = Dispatchers.IO,
start = CoroutineStart.LAZY,
) {
try {
runCatching { repository.getItemDetails(itemId) }
.getOrNull()
?.also { details ->
synchronized(metadataCache) { metadataCache[itemId] = details }
}
} finally {
synchronized(metadataCache) { metadataInFlight.remove(itemId) }
}
}
metadataInFlight[itemId] = request
request.start()
return request
}
fun removeFromContinueWatching(item: BaseItem) { fun removeFromContinueWatching(item: BaseItem) {
val previous = _state.value val previous = _state.value
_state.update { state -> _state.update { state ->
@@ -830,6 +882,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/ */
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem = internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
(metadata ?: item).copy( (metadata ?: item).copy(
// A cache entry from an older/incomplete route must not erase identity or prose
// the current route already knows. Null Overview means the field was not present;
// an explicitly empty Overview is still a complete answer from Emby.
id = item.id,
name = metadata?.name?.takeIf(String::isNotBlank) ?: item.name,
type = metadata?.type?.takeIf(String::isNotBlank) ?: item.type,
overview = metadata?.overview ?: item.overview,
// Detail requests enrich the facts panel, but their Fields list does not ask Emby // Detail requests enrich the facts panel, but their Fields list does not ask Emby
// for artwork. Keep the row card's image identity when that sparse response arrives // for artwork. Keep the row card's image identity when that sparse response arrives
// after focus settles. This matters again after the 32-entry metadata cache turns // after focus settles. This matters again after the 32-entry metadata cache turns
@@ -854,11 +913,31 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI
?: item.parentLogoImageTag, ?: item.parentLogoImageTag,
userData = item.userData ?: metadata?.userData, userData = item.userData ?: metadata?.userData,
membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true, membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
// A detail response is Emby's item with gateway decorations only where the
// gateway can resolve them again. Keep lifecycle metadata already carried by a
// schedule or personalised row so the detail page does not lose a better answer.
membyLifecycle = metadata?.membyLifecycle ?: item.membyLifecycle,
membyLifecycleText = metadata?.membyLifecycleText ?: item.membyLifecycleText,
membyRecommendationReason = item.membyRecommendationReason membyRecommendationReason = item.membyRecommendationReason
?: metadata?.membyRecommendationReason, ?: metadata?.membyRecommendationReason,
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility, membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
) )
/**
* Whether a cached object can stand in for the detail endpoint's response.
*
* Search and row payloads deliberately omit Overview, so null is the useful provenance
* signal: it means a lightweight or stale object must not suppress the full-item request.
* An empty string is different — Emby answered the requested field and the title genuinely
* has no description.
*/
internal fun detailMetadataComplete(itemId: String, metadata: BaseItem?): Boolean =
metadata != null &&
metadata.id == itemId &&
metadata.name.isNotBlank() &&
metadata.type.isNotBlank() &&
metadata.overview != null
/** /**
* Applies a live launcher response without making cached recommendation shelves disappear * Applies a live launcher response without making cached recommendation shelves disappear
* while the gateway rebuilds its recommendation cache in the background. * while the gateway rebuilds its recommendation cache in the background.
@@ -383,7 +383,15 @@ internal fun SeriesDetailContent(
// Held rather than rebuilt per composition, as on the movie page: the scaffold takes // Held rather than rebuilt per composition, as on the movie page: the scaffold takes
// lists, and a new identity for them on every focus move recomposes the fact row. // lists, and a new identity for them on every focus move recomposes the fact row.
val heroFacts = remember(item.id, seasons.size, item.productionYear, item.officialRating) { val heroFacts = remember(
item.id,
seasons.size,
item.productionYear,
item.officialRating,
item.status,
item.membyLifecycle,
item.membyLifecycleText,
) {
heroFacts(item, seasons.size) heroFacts(item, seasons.size)
} }
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) } val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
@@ -64,9 +64,9 @@ fun remainingLabel(item: BaseItem): String? {
} }
/** /**
* The quiet line directly under the title: year, length, certificate. Deliberately short * The quiet line directly under the title: year, length, certificate and, for a series,
* the score sits beside the title and the genres are a credit row, so this stays at three * its production status. The score sits beside the title and the genres are a credit row,
* items and never has to compete for the width. * so non-series titles stay at three items and never have to compete for the width.
* *
* [seasonCount] replaces the runtime for a series; pass 0 for anything else. * [seasonCount] replaces the runtime for a series; pass 0 for anything else.
*/ */
@@ -78,6 +78,28 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
item.runtimeMinutes?.let { add(formatRuntime(it)) } item.runtimeMinutes?.let { add(formatRuntime(it)) }
} }
item.officialRating?.takeIf(String::isNotBlank)?.let(::add) item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
seriesStatusLabel(item)?.let(::add)
}
/**
* The reliable subset of series lifecycle values carried by Emby or the gateway.
*
* Unknown values are omitted instead of being reformatted into a claim the client does not
* understand. The American spelling is accepted only as an upstream wire value; viewers
* always see New Zealand English.
*/
fun seriesStatusLabel(item: BaseItem): String? {
if (!item.isSeries) return null
val status = item.membyLifecycleText?.takeIf(String::isNotBlank)
?: item.membyLifecycle?.takeIf(String::isNotBlank)
?: item.status?.takeIf(String::isNotBlank)
?: return null
return when (status.trim().lowercase(Locale.US)) {
"continuing" -> "Continuing"
"ended" -> "Ended"
"cancelled", "canceled" -> "Cancelled"
else -> null
}
} }
/** /**
@@ -71,6 +71,28 @@ class ContinueWatchingResumeTest {
assertEquals("The richer record fetched after focus.", focused.overview) assertEquals("The richer record fetched after focus.", focused.overview)
} }
@Test
fun `detail metadata keeps server lifecycle carried by the row card`() {
val card = BaseItem(
id = "series",
name = "The Series",
type = "Series",
membyLifecycle = "ended",
membyLifecycleText = "ENDED",
)
val sparseDetails = BaseItem(
id = "series",
name = "The Series",
type = "Series",
status = null,
)
val focused = focusedItemWithMetadata(card, sparseDetails)
assertEquals("ended", focused.membyLifecycle)
assertEquals("ENDED", focused.membyLifecycleText)
}
@Test @Test
fun `pressed card resume point outranks a prefetched zero`() { fun `pressed card resume point outranks a prefetched zero`() {
assertEquals( assertEquals(
@@ -0,0 +1,90 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DetailItemStateTest {
@Test
fun `lightweight cached item does not count as loaded detail metadata`() {
val searchResult = BaseItem(id = "series-1", name = "The Show", type = "Series")
val noDescription = searchResult.copy(overview = "")
assertFalse(detailMetadataComplete(searchResult.id, searchResult))
assertFalse(detailMetadataComplete("series-2", noDescription))
assertTrue(detailMetadataComplete(noDescription.id, noDescription))
}
@Test
fun `sparse Search focus cannot erase loaded detail metadata`() {
val searchResult = BaseItem(id = "series-1", name = "The Show", type = "Series")
val details = searchResult.copy(
overview = "The full description.",
genres = listOf("Drama"),
)
val reopened = detailItemForRoute(
selected = searchResult,
focused = searchResult,
detailMetadata = details,
)
assertEquals("series-1", reopened.id)
assertEquals("The full description.", reopened.overview)
assertEquals(listOf("Drama"), reopened.genres)
}
@Test
fun `focus from another Search result cannot change the open detail identity`() {
val selected = BaseItem(id = "series-1", name = "First Show", type = "Series")
val details = selected.copy(overview = "First show description.")
val otherFocus = BaseItem(
id = "series-2",
name = "Second Show",
type = "Series",
overview = "Second show description.",
)
val openItem = detailItemForRoute(selected, otherFocus, details)
assertEquals("series-1", openItem.id)
assertEquals("First Show", openItem.name)
assertEquals("First show description.", openItem.overview)
}
@Test
fun `current route user state is merged with cached details`() {
val selected = BaseItem(id = "movie-1", name = "The Film", type = "Movie")
val focused = selected.copy(
userData = UserItemData(isFavorite = true, playbackPositionTicks = 42_000_000L),
)
val details = selected.copy(overview = "The full description.")
val openItem = detailItemForRoute(selected, focused, details)
assertEquals("The full description.", openItem.overview)
assertEquals(true, openItem.userData?.isFavorite)
assertEquals(42_000_000L, openItem.userData?.playbackPositionTicks)
}
@Test
fun `incomplete cached metadata cannot erase route description`() {
val selected = BaseItem(
id = "series-1",
name = "The Show",
type = "Series",
overview = "Description already known by this route.",
)
val incomplete = BaseItem(id = "series-1")
val openItem = detailItemForRoute(selected, selected, incomplete)
assertEquals("series-1", openItem.id)
assertEquals("The Show", openItem.name)
assertEquals("Series", openItem.type)
assertEquals("Description already known by this route.", openItem.overview)
}
}
@@ -5,6 +5,7 @@ import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.availableSeasons import com.ponzischeme89.memby.ui.detail.availableSeasons
import com.ponzischeme89.memby.ui.detail.defaultSeason import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.episodesForSeason import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch
import com.ponzischeme89.memby.ui.detail.primaryActionLabel import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import com.ponzischeme89.memby.ui.detail.seasonLabel import com.ponzischeme89.memby.ui.detail.seasonLabel
@@ -91,4 +92,56 @@ class SeriesDetailsTest {
assertEquals("Specials", seasonLabel(0)) assertEquals("Specials", seasonLabel(0))
assertEquals("Season 3", seasonLabel(3)) assertEquals("Season 3", seasonLabel(3))
} }
@Test
fun `series status is appended to the shared hero facts`() {
val series = BaseItem(
id = "series",
name = "Series",
type = "Series",
productionYear = 2022,
officialRating = "TV-MA",
status = "Continuing",
)
assertEquals(
listOf("2022", "4 Seasons", "TV-MA", "Continuing"),
heroFacts(series, seasonCount = 4),
)
}
@Test
fun `series status is canonicalised from existing server metadata`() {
val series = BaseItem(
id = "series",
name = "Series",
type = "Series",
status = "Ended",
membyLifecycleText = "CANCELED",
)
assertEquals("Cancelled", heroFacts(series).last())
assertEquals("Ended", heroFacts(series.copy(membyLifecycleText = null)).last())
}
@Test
fun `unknown or unsupported status is omitted cleanly`() {
val unknownSeries = BaseItem(
id = "series",
name = "Series",
type = "Series",
productionYear = 2022,
status = "Upcoming",
)
val movie = BaseItem(
id = "movie",
name = "Movie",
type = "Movie",
productionYear = 2022,
status = "Continuing",
)
assertEquals(listOf("2022"), heroFacts(unknownSeries))
assertEquals(listOf("2022"), heroFacts(movie))
}
} }
+6 -5
View File
@@ -238,11 +238,12 @@ fuzzily joined without an explicit operator decision.
Keeping the pool deliberately much larger than the cards shown gives the request-time Keeping the pool deliberately much larger than the cards shown gives the request-time
`minutes` filter enough headroom. `/v1/for-you` reads up to 240 ranked candidates and `minutes` filter enough headroom. `/v1/for-you` reads up to 240 ranked candidates and
derives several de-duplicated shelves: top picks, up to two distinct “Because you derives several de-duplicated shelves: top picks, up to two distinct title-based shelves,
finished …” shelves, up to two genre shelves, and a television-compatible shelf when up to two theme or genre shelves, and a television-compatible shelf when there is enough
there is enough playback evidence. Completed-title evidence is distributed playback evidence. Reasons prefer defensible title relationships, specific themes and
deterministically across relevant candidates rather than allowing the newest Drama viewing eras, then cast or creator matches; a top-level genre such as Drama is only a
title to explain every Drama recommendation. fallback. One source title is capped and distributed deterministically across relevant
candidates rather than being allowed to explain an entire row.
The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Tracearr imports The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Tracearr imports
stay frequent but do not rebuild pools. A session's first transition to stopped/completed stay frequent but do not rebuild pools. A session's first transition to stopped/completed
+8 -1
View File
@@ -33,7 +33,7 @@ const (
maxPreparedCandidates = 750 maxPreparedCandidates = 750
// Increment only when stored eligibility, scoring, or explanation behavior changes. // Increment only when stored eligibility, scoring, or explanation behavior changes.
preparedAlgorithmVersion = "2026-07-31.2" preparedAlgorithmVersion = "2026-08-23.1"
) )
type ImportResult struct { type ImportResult struct {
@@ -676,7 +676,14 @@ func buildPreparedRows(
"for-you:because:", "for-you:because:",
func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID }, func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID },
func(item store.PreparedForYouItem) string { func(item store.PreparedForYouItem) string {
switch item.ReasonKind {
case "completed-title":
return "Because you finished " + item.ReasonSourceTitle return "Because you finished " + item.ReasonSourceTitle
case "favourite-title":
return "Because you like " + item.ReasonSourceTitle
default:
return "Because you watched " + item.ReasonSourceTitle
}
}, },
2, 2,
func(item store.PreparedForYouItem) bool { func(item store.PreparedForYouItem) bool {
+1
View File
@@ -214,6 +214,7 @@ func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T)
CompatibilityScore: 0.8, CompatibilityScore: 0.8,
CompatibilityLabel: "Direct plays well on this TV", CompatibilityLabel: "Direct plays well on this TV",
RecommendationReason: "Because you finished " + sourceTitle, RecommendationReason: "Because you finished " + sourceTitle,
ReasonKind: "completed-title",
ReasonGenre: genre, ReasonSourceItemID: sourceID, ReasonGenre: genre, ReasonSourceItemID: sourceID,
ReasonSourceTitle: sourceTitle, ReasonSourceTitle: sourceTitle,
}) })
+21 -22
View File
@@ -444,28 +444,11 @@ func explainRecommendation(
compatibility compatibilityProfile, compatibility compatibilityProfile,
browsed bool, browsed bool,
) (string, string) { ) (string, string) {
top := profile.TopGenres(5)
matched := ""
for _, wanted := range top {
for _, genre := range item.Genres {
if strings.EqualFold(wanted, genre) {
matched = genre
break
}
}
if matched != "" {
break
}
}
reasons := make([]string, 0, 3) reasons := make([]string, 0, 3)
if browsed { if browsed {
reasons = append(reasons, "You explored this recently") reasons = append(reasons, "You explored this recently")
} else if matched != "" {
reasons = append(reasons, "Matches your "+matched+" viewing")
} else if len(profile.Seeds) > 0 {
reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name)
} else { } else {
reasons = append(reasons, "Matches your recent viewing") reasons = append(reasons, strongestPersonalReason(profile, item).Text)
} }
if availableMinutes > 0 && item.RuntimeMinutes() > 0 { if availableMinutes > 0 && item.RuntimeMinutes() > 0 {
reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window") reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window")
@@ -758,7 +741,7 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed str
ID: definition.ID, ID: definition.ID,
Title: definition.Title, Title: definition.Title,
Kind: definition.Kind, Kind: definition.Kind,
Items: Raws(items), Items: enrichRecommendationReasons(profile, items),
}) })
} }
return rows return rows
@@ -1054,11 +1037,17 @@ func (e *Engine) similarRow(
return Row{}, false return Row{}, false
} }
items = diversifyRanked(items, variation+":similar:"+seed.ID, 5) items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raws = append(raws, enrichRecommendation(
item.Raw, "Because you watched "+seed.Name, "",
))
}
return Row{ return Row{
ID: "similar:" + seed.ID, ID: "similar:" + seed.ID,
Title: "Because you watched " + seed.Name, Title: "Because you watched " + seed.Name,
Kind: "similar", Kind: "similar",
Items: Raws(items), Items: raws,
}, true }, true
} }
@@ -1208,7 +1197,7 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile
ID: "recommended", ID: "recommended",
Title: "Recommended from your watching history", Title: "Recommended from your watching history",
Kind: "recommended", Kind: "recommended",
Items: Raws(items), Items: enrichRecommendationReasons(profile, items),
}, true }, true
} }
@@ -1240,6 +1229,16 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile
ID: "recommended", ID: "recommended",
Title: "Recommended from your watching history", Title: "Recommended from your watching history",
Kind: "recommended", Kind: "recommended",
Items: Raws(items), Items: enrichRecommendationReasons(profile, items),
}, true }, true
} }
func enrichRecommendationReasons(profile Profile, items []Item) []json.RawMessage {
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raws = append(raws, enrichRecommendation(
item.Raw, strongestPersonalReason(profile, item).Text, "",
))
}
return raws
}
+8 -2
View File
@@ -248,6 +248,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") { if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") {
t.Fatalf("unexpected first row: %+v", rows[0]) t.Fatalf("unexpected first row: %+v", rows[0])
} }
if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason":"Because you watched `) {
t.Fatalf("similar recommendation did not carry its server reason: %s", rows[0].Items[0])
}
last := rows[len(rows)-1] last := rows[len(rows)-1]
if last.Kind != "recommended" || last.Title != "Recommended from your watching history" { if last.Kind != "recommended" || last.Title != "Recommended from your watching history" {
t.Fatalf("unexpected history row: %+v", last) t.Fatalf("unexpected history row: %+v", last)
@@ -255,6 +258,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
if last.ID != "recommended" { if last.ID != "recommended" {
t.Fatalf("history row id should be stable, got %q", last.ID) t.Fatalf("history row id should be stable, got %q", last.ID)
} }
if !strings.Contains(string(last.Items[0]), `"MembyRecommendationReason":`) {
t.Fatalf("history recommendation did not carry a server reason: %s", last.Items[0])
}
} }
func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) { func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
@@ -485,7 +491,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
evidence := map[string][]PreparedEvidence{ evidence := map[string][]PreparedEvidence{
"drama": {{ "drama": {{
ItemID: "arrival", Title: "Arrival", ItemID: "arrival", Title: "Arrival",
Genres: []string{"Drama", "Science Fiction"}, Genres: []string{"Drama", "Science Fiction"}, Completed: true,
}}, }},
} }
counts := map[string]int{} counts := map[string]int{}
@@ -503,7 +509,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 { if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"]) t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
} }
if kinds["genre"] == 0 { if kinds["theme"] == 0 {
t.Fatalf("reason kinds were not mixed: %+v", kinds) t.Fatalf("reason kinds were not mixed: %+v", kinds)
} }
} }
+267 -57
View File
@@ -7,63 +7,288 @@ import (
"time" "time"
) )
// Why a title suits one viewer, in that viewer's own words.
//
// This is deliberately separate from Score. The scorer decides *order* and is allowed to
// be opaque; this decides *wording* and must never claim an affinity the profile did not
// actually learn — every reason below is read straight out of the weights built from real
// history, so a viewer who has watched nothing gets the honest, taste-free ones.
// ReasonLimit is what fits on one line of a detail page without wrapping. Beyond three
// the strip stops reading as an explanation and starts reading as marketing.
const ReasonLimit = 3 const ReasonLimit = 3
// reasonFloor is the weight below which an affinity is a coincidence rather than a
// habit — one stray episode should not put a genre on the screen as a reason.
const reasonFloor = 0.35 const reasonFloor = 0.35
const decadeReasonFloor = 1.5
// Why returns up to limit short phrases explaining the item to this viewer, strongest type personalReason struct {
// first. Never nil: a profile with nothing in it still yields the catalogue facts. Text string
Kind string
Genre string
SourceID string
SourceTitle string
}
// Why returns short, human-readable reasons in evidence-strength order. Ranking stays
// separate: none of the scorer's technical values or reason codes reach this wording.
func Why(profile Profile, item Item, limit int) []string { func Why(profile Profile, item Item, limit int) []string {
if limit <= 0 { if limit <= 0 {
limit = ReasonLimit limit = ReasonLimit
} }
reasons := make([]string, 0, limit) reasons := make([]string, 0, limit)
add := func(reason string) { for _, candidate := range personalReasons(profile, item) {
if len(reasons) < limit && reason != "" { if len(reasons) == limit {
reasons = append(reasons, reason) return reasons
} }
reasons = append(reasons, candidate.Text)
} }
if len(reasons) < limit && item.CommunityRating >= 7.5 {
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor { reasons = append(reasons, "Well rated ("+
add("Because you watch " + genre) strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64)+")")
} }
if name, weight := heaviestPerson(profile, item); weight >= reasonFloor { if len(reasons) < limit && item.ProductionYear > 0 &&
add("You've watched " + name + " before") time.Now().Year()-item.ProductionYear <= 1 {
reasons = append(reasons, "A recent release")
} }
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor { if len(reasons) == 0 {
add("More from " + studio) reasons = append(reasons, "Recommended from your library")
}
// Catalogue facts, used to fill the strip out. They are true for everyone, which is
// exactly why they come last: they explain the title, not the viewer.
// Kept short deliberately. Three chips share one line on a 960dp TV, and this is the
// one that most often lands third, where a long phrase is the one that gets clipped.
if item.CommunityRating >= 7.5 {
add("Well rated (" + strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64) + ")")
}
if item.ProductionYear > 0 && time.Now().Year()-item.ProductionYear <= 1 {
add("A recent release")
}
if len(reasons) == 0 && len(item.Genres) > 0 {
add(strings.TrimSpace(item.Genres[0]) + " from your library")
} }
return reasons return reasons
} }
// heaviest picks the wanted key with the most weight behind it, matched case-insensitively func strongestPersonalReason(profile Profile, item Item) personalReason {
// because Emby's own tagging is not consistent about it. Ties break alphabetically so the if reasons := personalReasons(profile, item); len(reasons) > 0 {
// same profile and item always produce the same sentence. return reasons[0]
}
return personalReason{Text: "Recommended from your library", Kind: "generic"}
}
// personalReasons is the human-facing priority: related viewing, specific themes,
// viewing era, cast/creator, studio, then a broad genre as the final personal fallback.
func personalReasons(profile Profile, item Item) []personalReason {
reasons := make([]personalReason, 0, 6)
if reason := relatedViewingReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if reason := themeReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if reason := decadeReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if reason := personReason(profile, item); reason.Text != "" {
reasons = append(reasons, reason)
}
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor {
reasons = append(reasons, personalReason{Text: "More from " + studio, Kind: "studio"})
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
reasons = append(reasons, personalReason{
Text: "Because you watch " + genre, Kind: "genre", Genre: genre,
})
}
return uniquePersonalReasons(reasons)
}
func relatedViewingReason(profile Profile, candidate Item) personalReason {
bestIndex, bestStrength := -1, 0
for index, evidence := range profile.ReasonEvidence {
if sameTitle(evidence.Item, candidate) {
continue
}
strength := titleRelationshipStrength(evidence.Item, candidate)
if strength > bestStrength {
bestIndex, bestStrength = index, strength
}
}
if bestIndex < 0 {
return personalReason{}
}
evidence := profile.ReasonEvidence[bestIndex]
title := evidenceTitle(evidence.Item)
verb, kind := "watched ", "recent-title"
switch {
case evidence.Favourite:
verb, kind = "like ", "favourite-title"
case evidence.Item.UserData.Played && !strings.EqualFold(evidence.Item.Type, "Episode"):
verb, kind = "finished ", "completed-title"
}
return personalReason{
Text: "Because you " + verb + title, Kind: kind,
SourceID: evidenceID(evidence.Item), SourceTitle: title,
}
}
func titleRelationshipStrength(source, candidate Item) int {
if source.CollectionName != "" && candidate.CollectionName != "" &&
strings.EqualFold(strings.TrimSpace(source.CollectionName), strings.TrimSpace(candidate.CollectionName)) {
return 140
}
shared, specific := sharedGenreCount(source.Genres, candidate.Genres)
switch {
case shared >= 2:
return 100 + specific*10
case specific >= 1:
return 80
default:
return 0
}
}
func sharedGenreCount(left, right []string) (shared, specific int) {
seen := map[string]bool{}
for _, a := range left {
a = strings.TrimSpace(a)
if a == "" {
continue
}
for _, b := range right {
if !strings.EqualFold(a, strings.TrimSpace(b)) {
continue
}
key := strings.ToLower(a)
if !seen[key] {
seen[key] = true
shared++
if !isBroadGenre(a) {
specific++
}
}
break
}
}
return shared, specific
}
func themeReason(profile Profile, item Item) personalReason {
type match struct {
name string
weight float64
}
matches := make([]match, 0, len(item.Genres))
for _, genre := range item.Genres {
genre = strings.TrimSpace(genre)
if weight := weightFold(profile.GenreWeights, genre); genre != "" && weight >= reasonFloor {
matches = append(matches, match{genre, weight})
}
}
sort.SliceStable(matches, func(i, j int) bool {
iBroad, jBroad := isBroadGenre(matches[i].name), isBroadGenre(matches[j].name)
if iBroad != jBroad {
return !iBroad
}
return matches[i].weight > matches[j].weight
})
if len(matches) == 0 || isBroadGenre(matches[0].name) {
return personalReason{}
}
primary := matches[0].name
phrase := strings.ToLower(primary)
for _, candidate := range matches[1:] {
if isBroadGenre(candidate.name) {
phrase += " " + pluralGenre(candidate.name)
break
}
}
return personalReason{
Text: "Because you like " + phrase, Kind: "theme", Genre: primary,
}
}
func decadeReason(profile Profile, item Item) personalReason {
if item.ProductionYear <= 0 {
return personalReason{}
}
decade := item.ProductionYear / 10 * 10
if profile.DecadeWeights[decade] < decadeReasonFloor {
return personalReason{}
}
noun := "titles"
if strings.EqualFold(item.Type, "Series") {
noun = "series"
} else if strings.EqualFold(item.Type, "Movie") {
noun = "films"
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor && isBroadGenre(genre) {
noun = pluralGenre(genre)
}
return personalReason{
Text: "Because you've been watching " + strconv.Itoa(decade) + "s " + noun,
Kind: "decade",
}
}
func personReason(profile Profile, item Item) personalReason {
best := Person{}
bestWeight := 0.0
for _, person := range item.People {
if !isExplainablePerson(person.Type) {
continue
}
weight := weightFold(profile.PersonWeights, person.Name)
if weight > bestWeight || weight == bestWeight && person.Name < best.Name {
best, bestWeight = person, weight
}
}
if bestWeight < reasonFloor {
return personalReason{}
}
switch strings.ToLower(strings.TrimSpace(best.Type)) {
case "director", "writer":
return personalReason{Text: "More from " + best.Name, Kind: "creator"}
default:
return personalReason{Text: "Because you watch " + best.Name, Kind: "person"}
}
}
func evidenceTitle(item Item) string {
if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" {
return strings.TrimSpace(item.SeriesName)
}
return strings.TrimSpace(item.Name)
}
func evidenceID(item Item) string {
if item.SeriesID != "" {
return item.SeriesID
}
return item.ID
}
func sameTitle(left, right Item) bool {
if evidenceID(left) != "" && evidenceID(left) == evidenceID(right) {
return true
}
leftKey, rightKey := left.SeenKey(), right.SeenKey()
return leftKey != "" && leftKey == rightKey
}
func isBroadGenre(genre string) bool {
switch strings.ToLower(strings.TrimSpace(genre)) {
case "action", "adventure", "comedy", "drama", "family", "thriller":
return true
default:
return false
}
}
func pluralGenre(genre string) string {
switch strings.ToLower(strings.TrimSpace(genre)) {
case "comedy":
return "comedies"
case "family":
return "family titles"
case "action", "adventure":
return strings.ToLower(strings.TrimSpace(genre)) + " titles"
default:
return strings.ToLower(strings.TrimSpace(genre)) + "s"
}
}
func uniquePersonalReasons(reasons []personalReason) []personalReason {
out := make([]personalReason, 0, len(reasons))
seen := map[string]bool{}
for _, reason := range reasons {
key := strings.ToLower(strings.TrimSpace(reason.Text))
if key == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, reason)
}
return out
}
func heaviest(weights map[string]float64, wanted []string) (string, float64) { func heaviest(weights map[string]float64, wanted []string) (string, float64) {
best, bestWeight := "", 0.0 best, bestWeight := "", 0.0
for _, candidate := range wanted { for _, candidate := range wanted {
@@ -72,32 +297,17 @@ func heaviest(weights map[string]float64, wanted []string) (string, float64) {
continue continue
} }
weight := weightFold(weights, candidate) weight := weightFold(weights, candidate)
if weight <= 0 { if weight > bestWeight || weight == bestWeight && weight > 0 && candidate < best {
continue
}
if weight > bestWeight || (weight == bestWeight && candidate < best) {
best, bestWeight = candidate, weight best, bestWeight = candidate, weight
} }
} }
return best, bestWeight return best, bestWeight
} }
func heaviestPerson(profile Profile, item Item) (string, float64) {
names := make([]string, 0, len(item.People))
for _, person := range item.People {
if isExplainablePerson(person.Type) {
names = append(names, person.Name)
}
}
return heaviest(profile.PersonWeights, names)
}
func round1(value float64) float64 { func round1(value float64) float64 {
return float64(int(value*10+0.5)) / 10 return float64(int(value*10+0.5)) / 10
} }
// TopPeople is the explanation layer's view of the cast a viewer follows, heaviest first.
// Exported for the admin page, which shows what the engine believes about a household.
func (p Profile) TopPeople(n int) []string { func (p Profile) TopPeople(n int) []string {
type kv struct { type kv struct {
name string name string
+105 -8
View File
@@ -30,17 +30,17 @@ const thrillerHistory = `{
"People":[{"Name":"Denis Villeneuve","Type":"Director"},{"Name":"Emily Blunt","Type":"Actor"}] "People":[{"Name":"Denis Villeneuve","Type":"Director"},{"Name":"Emily Blunt","Type":"Actor"}]
}` }`
func TestWhyNamesTheGenreTheViewerActuallyWatches(t *testing.T) { func TestWhyPrefersTheRelatedTitleOverItsGenres(t *testing.T) {
profile := explainProfile(t, thrillerHistory) profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{ candidate := explainItem(t, `{
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller"], "Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller","Crime"],
"People":[{"Name":"Denis Villeneuve","Type":"Director"}] "People":[{"Name":"Denis Villeneuve","Type":"Director"}]
}`) }`)
reasons := Why(profile, candidate, ReasonLimit) reasons := Why(profile, candidate, ReasonLimit)
if len(reasons) == 0 || reasons[0] != "Because you watch Thriller" { if len(reasons) == 0 || reasons[0] != "Because you watched Sicario" {
t.Fatalf("expected the genre reason first, got %v", reasons) t.Fatalf("expected the related viewing reason first, got %v", reasons)
} }
if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") { if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") {
t.Fatalf("expected the shared director to be named, got %v", reasons) t.Fatalf("expected the shared director to be named, got %v", reasons)
@@ -73,7 +73,7 @@ func TestWhyAlwaysSaysSomething(t *testing.T) {
reasons := Why(Profile{}, candidate, ReasonLimit) reasons := Why(Profile{}, candidate, ReasonLimit)
if len(reasons) != 1 || reasons[0] != "Drama from your library" { if len(reasons) != 1 || reasons[0] != "Recommended from your library" {
t.Fatalf("an empty profile should still explain the title, got %v", reasons) t.Fatalf("an empty profile should still explain the title, got %v", reasons)
} }
} }
@@ -98,9 +98,9 @@ func TestWhyIsCappedAndOrdered(t *testing.T) {
t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons) t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons)
} }
want := []string{ want := []string{
"Because you watch Thriller", "Because you watched Sicario",
"You've watched Emily Blunt before", "Because you like crime thrillers",
"More from Lionsgate", "Because you watch Emily Blunt",
} }
for i, reason := range want { for i, reason := range want {
if reasons[i] != reason { if reasons[i] != reason {
@@ -109,6 +109,103 @@ func TestWhyIsCappedAndOrdered(t *testing.T) {
} }
} }
func TestWhyDoesNotTreatDramaAloneAsATitleRelationship(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"A Drama","Type":"Movie","Genres":["Drama"]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"Another Drama","Type":"Movie","Genres":["Drama"]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if reasons[0] != "Because you watch Drama" {
t.Fatalf("broad genre should be the fallback, got %v", reasons)
}
if strings.Contains(strings.Join(reasons, "|"), "A Drama") {
t.Fatalf("Drama alone invented a title relationship: %v", reasons)
}
}
func TestWhyUsesSpecificSubgenreBeforeBroadGenre(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"Crime One","Type":"Movie","Genres":["Crime","Drama"]
}`)
// Do not retain title evidence here: this test isolates the learned theme wording.
profile.ReasonEvidence = nil
candidate := explainItem(t, `{
"Id":"c1","Name":"Crime Two","Type":"Series","Genres":["Drama","Crime"]
}`)
if got := Why(profile, candidate, 1); len(got) != 1 || got[0] != "Because you like crime dramas" {
t.Fatalf("specific theme reason = %v", got)
}
}
func TestWhyUsesCreatorBeforeBroadGenre(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"The First","Type":"Movie","Genres":["Drama"],
"People":[{"Name":"Vince Gilligan","Type":"Writer"}]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"The Second","Type":"Series","Genres":["Drama"],
"People":[{"Name":"Vince Gilligan","Type":"Writer"}]
}`)
if got := Why(profile, candidate, 1); len(got) != 1 || got[0] != "More from Vince Gilligan" {
t.Fatalf("creator reason = %v", got)
}
}
func TestWhyDistinguishesFinishedTitlesFromEpisodes(t *testing.T) {
finished := explainItem(t, `{
"Id":"h1","Name":"Breaking Bad","Type":"Series","Genres":["Crime","Drama"],
"UserData":{"Played":true}
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"Better Call Saul","Type":"Series","Genres":["Crime","Drama"]
}`)
if got := Why(BuildProfile([]Item{finished}, nil), candidate, 1); got[0] != "Because you finished Breaking Bad" {
t.Fatalf("completed title reason = %v", got)
}
episodeHistory := explainItem(t, `{
"Id":"ep1","Name":"Pilot","Type":"Episode","SeriesId":"show-1",
"SeriesName":"Breaking Bad","Genres":["Crime","Drama"],"UserData":{"Played":true}
}`)
if got := Why(BuildProfile([]Item{episodeHistory}, nil), candidate, 1); got[0] != "Because you watched Breaking Bad" {
t.Fatalf("completed episode overstated series completion: %v", got)
}
}
func TestWhyCanUseARepeatedViewingEra(t *testing.T) {
profile := explainProfile(t,
`{"Id":"h1","Name":"One","Type":"Movie","ProductionYear":2003,"Genres":["Drama"]}`,
`{"Id":"h2","Name":"Two","Type":"Movie","ProductionYear":2007,"Genres":["Drama"]}`,
)
profile.ReasonEvidence = nil
candidate := explainItem(t, `{
"Id":"c1","Name":"Three","Type":"Movie","ProductionYear":2005,"Genres":["Drama"]
}`)
if got := Why(profile, candidate, 1); got[0] != "Because you've been watching 2000s dramas" {
t.Fatalf("viewing-era reason = %v", got)
}
}
func TestWhyCanNameAFavouriteTitle(t *testing.T) {
favourite := explainItem(t, `{
"Id":"h1","Name":"The Wire","Type":"Series","Genres":["Crime","Drama"]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"We Own This City","Type":"Series","Genres":["Crime","Drama"]
}`)
if got := Why(BuildProfile(nil, []Item{favourite}), candidate, 1); got[0] != "Because you like The Wire" {
t.Fatalf("favourite-title reason = %v", got)
}
}
func TestWhyIsStableAcrossCalls(t *testing.T) { func TestWhyIsStableAcrossCalls(t *testing.T) {
profile := explainProfile(t, thrillerHistory) profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{ candidate := explainItem(t, `{
+48 -49
View File
@@ -25,6 +25,7 @@ type PreparedEvidence struct {
ItemID string `json:"itemId"` ItemID string `json:"itemId"`
Title string `json:"title"` Title string `json:"title"`
Genres []string `json:"genres,omitempty"` Genres []string `json:"genres,omitempty"`
Completed bool `json:"completed,omitempty"`
} }
type PreparedTitleAffinity struct { type PreparedTitleAffinity struct {
@@ -128,7 +129,7 @@ func (e *Engine) PrepareForYou(
index := newCatalogueIndex(catalogue) index := newCatalogueIndex(catalogue)
evidenceByGenre := map[string][]PreparedEvidence{} evidenceByGenre := map[string][]PreparedEvidence{}
evidenceSeen := map[string]bool{} evidenceSeen := map[string]bool{}
addCompletedEvidence := func(item Item, sessionID string) { addCompletedEvidence := func(item Item, sessionID string, completed bool) {
itemID := item.ID itemID := item.ID
title := item.Name title := item.Name
if strings.EqualFold(item.Type, "Episode") && if strings.EqualFold(item.Type, "Episode") &&
@@ -152,6 +153,7 @@ func (e *Engine) PrepareForYou(
ItemID: itemID, ItemID: itemID,
Title: title, Title: title,
Genres: append([]string(nil), item.Genres...), Genres: append([]string(nil), item.Genres...),
Completed: completed,
}, },
) )
} }
@@ -160,7 +162,7 @@ func (e *Engine) PrepareForYou(
// explanation pool breadth even when Tracearr title matching is sparse. // explanation pool breadth even when Tracearr title matching is sparse.
for _, item := range history { for _, item := range history {
if item.UserData.Played { if item.UserData.Played {
addCompletedEvidence(item, "") addCompletedEvidence(item, "", !strings.EqualFold(item.Type, "Episode"))
} }
} }
titleAffinity := map[string]PreparedTitleAffinity{} titleAffinity := map[string]PreparedTitleAffinity{}
@@ -263,7 +265,9 @@ func (e *Engine) PrepareForYou(
} }
if completion >= 0.9 { if completion >= 0.9 {
addCompletedEvidence(item, session.ID) addCompletedEvidence(
item, session.ID, !strings.EqualFold(session.MediaType, "episode"),
)
} }
} }
@@ -559,50 +563,47 @@ func explainPreparedRecommendation(
evidenceByGenre map[string][]PreparedEvidence, evidenceByGenre map[string][]PreparedEvidence,
completedReasonCounts map[string]int, completedReasonCounts map[string]int,
) (reason, label, kind, genre string, evidence PreparedEvidence) { ) (reason, label, kind, genre string, evidence PreparedEvidence) {
for _, wanted := range profile.TopGenres(5) { strong := make([]PreparedEvidence, 0)
seenEvidence := map[string]bool{}
for _, candidateGenre := range item.Genres { for _, candidateGenre := range item.Genres {
if strings.EqualFold(wanted, candidateGenre) { options := evidenceByGenre[strings.ToLower(strings.TrimSpace(candidateGenre))]
genre = candidateGenre
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
strong := make([]PreparedEvidence, 0, len(options))
for _, option := range options { for _, option := range options {
if strongEvidenceMatch(item, option) { if seenEvidence[option.ItemID] || !strongEvidenceMatch(item, option) {
continue
}
seenEvidence[option.ItemID] = true
strong = append(strong, option) strong = append(strong, option)
} }
} }
if len(strong) > 0 { if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))] evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
} }
break useTitle := evidence.Title != "" && completedReasonCounts[evidence.ItemID] < 4
}
}
if genre != "" {
break
}
}
// Keep specific evidence prominent without letting it monopolise a row. One third
// of otherwise eligible cards deliberately uses the broader genre explanation,
// and no completed title can explain more than four candidates in a prepared pool.
useCompleted := evidence.Title != "" &&
stableEvidenceIndex("reason-kind:"+item.ID, 3) != 0 &&
completedReasonCounts[evidence.ItemID] < 4
switch { switch {
case useTitle:
verb := "watched "
kind = "recent-title"
if evidence.Completed {
verb, kind = "finished ", "completed-title"
}
reason = "Because you " + verb + evidence.Title
completedReasonCounts[evidence.ItemID]++
case browsed: case browsed:
reason, kind = "You explored this recently", "browsed" reason, kind = "You explored this recently", "browsed"
evidence = PreparedEvidence{} evidence = PreparedEvidence{}
case useCompleted:
reason, kind = "Because you finished "+evidence.Title, "completed-title"
completedReasonCounts[evidence.ItemID]++
case genre != "":
reason, kind = "Matches your "+genre+" viewing", "genre"
evidence = PreparedEvidence{}
case len(profile.Seeds) > 0:
reason, kind = "Inspired by "+profile.Seeds[0].Name, "recent-title"
evidence = PreparedEvidence{}
default: default:
reason, kind = "Matches your recent viewing", "generic" selected := strongestAvailableReason(profile, item, completedReasonCounts)
reason, kind, genre = selected.Text, selected.Kind, selected.Genre
if selected.SourceID != "" {
completedReasonCounts[selected.SourceID]++
evidence = PreparedEvidence{
ItemID: selected.SourceID, Title: selected.SourceTitle,
Completed: selected.Kind == "completed-title",
}
} else {
evidence = PreparedEvidence{} evidence = PreparedEvidence{}
} }
}
switch score := compatibilityScore(item, compatibility); { switch score := compatibilityScore(item, compatibility); {
case score > 0.2: case score > 0.2:
label = "Direct plays well on this TV" label = "Direct plays well on this TV"
@@ -615,24 +616,22 @@ func explainPreparedRecommendation(
return reason, label, kind, genre, evidence return reason, label, kind, genre, evidence
} }
func strongestAvailableReason(
profile Profile,
item Item,
titleReasonCounts map[string]int,
) personalReason {
for _, reason := range personalReasons(profile, item) {
if reason.SourceID == "" || titleReasonCounts[reason.SourceID] < 4 {
return reason
}
}
return personalReason{Text: "Recommended from your library", Kind: "generic"}
}
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool { func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
shared := 0 shared, specific := sharedGenreCount(item.Genres, evidence.Genres)
broadOnly := true return shared >= 2 || specific >= 1
for _, candidateGenre := range item.Genres {
for _, evidenceGenre := range evidence.Genres {
if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
continue
}
shared++
switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
case "action", "adventure", "comedy", "drama", "thriller":
default:
broadOnly = false
}
break
}
}
return shared >= 2 || shared == 1 && !broadOnly
} }
func stableEvidenceIndex(itemID string, size int) int { func stableEvidenceIndex(itemID string, size int) int {
+37
View File
@@ -109,6 +109,11 @@ type Profile struct {
// good reason to *tell* someone about a title and a poor reason to rank by it: two // good reason to *tell* someone about a title and a poor reason to rank by it: two
// films sharing an actor are often nothing alike. // films sharing an actor are often nothing alike.
PersonWeights map[string]float64 PersonWeights map[string]float64
// DecadeWeights and ReasonEvidence retain just enough of the source history for the
// explanation layer to say why a particular title fits. They do not participate in
// ranking: ordering and wording remain deliberately separate concerns.
DecadeWeights map[int]float64
ReasonEvidence []ReasonEvidence
// Seen holds item ids *and* series ids already watched or in progress, so a // Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through. // recommendation never suggests something the user is already partway through.
Seen map[string]bool Seen map[string]bool
@@ -116,6 +121,11 @@ type Profile struct {
Seeds []Seed Seeds []Seed
} }
type ReasonEvidence struct {
Item Item
Favourite bool
}
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 } func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
// Decode parses raw Emby items, keeping the original payload attached. // Decode parses raw Emby items, keeping the original payload attached.
@@ -141,14 +151,26 @@ func BuildProfile(history, favorites []Item) Profile {
GenreWeights: map[string]float64{}, GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{}, StudioWeights: map[string]float64{},
PersonWeights: map[string]float64{}, PersonWeights: map[string]float64{},
DecadeWeights: map[int]float64{},
Seen: map[string]bool{}, Seen: map[string]bool{},
SeenTitles: map[string]bool{}, SeenTitles: map[string]bool{},
} }
seedSeen := map[string]bool{} seedSeen := map[string]bool{}
tasteSeen := map[string]bool{} tasteSeen := map[string]bool{}
reasonSeen := map[string]bool{}
for i, item := range history { for i, item := range history {
profile.markSeen(item) profile.markSeen(item)
reasonItem := item
reasonItem.Raw = nil
reasonID := item.ID
if item.SeriesID != "" {
reasonID = item.SeriesID
}
if reasonID != "" && !reasonSeen[reasonID] {
reasonSeen[reasonID] = true
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{Item: reasonItem})
}
// Several episodes of one series are evidence for one taste, not several // Several episodes of one series are evidence for one taste, not several
// independent tastes. Keep the newest occurrence's recency weight and still // independent tastes. Keep the newest occurrence's recency weight and still
@@ -176,6 +198,15 @@ func BuildProfile(history, favorites []Item) Profile {
for _, item := range favorites { for _, item := range favorites {
profile.absorb(item, favoriteWeight) profile.absorb(item, favoriteWeight)
reasonItem := item
reasonItem.Raw = nil
reasonID := item.ID
if reasonID != "" && !reasonSeen[reasonID] {
reasonSeen[reasonID] = true
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{
Item: reasonItem, Favourite: true,
})
}
} }
return profile return profile
} }
@@ -223,6 +254,12 @@ func (p *Profile) absorbTaste(item Item, weight float64) {
p.PersonWeights[name] += weight p.PersonWeights[name] += weight
} }
} }
if item.ProductionYear > 0 {
if p.DecadeWeights == nil {
p.DecadeWeights = map[int]float64{}
}
p.DecadeWeights[item.ProductionYear/10*10] += weight
}
} }
// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as // isExplainablePerson keeps the cast list down to the roles a viewer would recognise as