diff --git a/CLAUDE.md b/CLAUDE.md index aa9c4eb..ef7aafb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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. +- **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, extras) and delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so they can be screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc8f50d..24e779d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,7 +62,7 @@ val projectNoticeText = rootProject.file("NOTICE").readText() .replace("https://g.sublogue.com/admin/memby", membySourceUrl) -val defaultVersionName = "0.3.05" +val defaultVersionName = "0.3.06" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 3338ee9..c1fb195 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -32,12 +32,12 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties 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.Color -import androidx.compose.ui.graphics.CornerRadius 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.layout.ContentScale import androidx.compose.ui.platform.LocalDensity diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index 4cbdd69..dbcab05 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -342,7 +342,18 @@ internal fun FocusedDetailsOverlay( airingNotice: AiringNotice? = null, ) { 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) { // 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 @@ -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 internal fun FocusedQuickActionsOverlay( homeViewModel: HomeViewModel, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 066c8b0..866d2c4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -17,8 +17,11 @@ import com.ponzischeme89.memby.data.millisecondsToTicks import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.UserItemData +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -173,6 +176,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private val metadataCache = object : LinkedHashMap(32, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 32 } + private val metadataInFlight = mutableMapOf>() /** Row engagement, buffered here and uploaded in batches. */ private val analytics = RowAnalytics() @@ -417,12 +421,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { detailPrefetchJob?.cancel() metadataJob = viewModelScope.launch(Dispatchers.IO) { delay(FOCUS_METADATA_DEBOUNCE_MS) - if (cached == null && !item.isSchedule) { - val details = runCatching { - repository.getItemDetails(item.id) - }.getOrNull() ?: return@launch + if (!item.isSchedule) { + val details = loadDetailMetadata(item.id) ?: return@launch val taggedDetails = focusedItemWithMetadata(item, details) - synchronized(metadataCache) { metadataCache[item.id] = taggedDetails } if (_focusedItem.value?.id == item.id) { _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 { + 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) { val previous = _state.value _state.update { state -> @@ -830,6 +882,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { */ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem = (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 // 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 @@ -854,11 +913,31 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI ?: item.parentLogoImageTag, userData = item.userData ?: metadata?.userData, 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 ?: metadata?.membyRecommendationReason, 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 * while the gateway rebuilds its recommendation cache in the background. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index 313eee5..746bf35 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -383,7 +383,15 @@ internal fun SeriesDetailContent( // 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. - 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) } val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt index d12824c..3140074 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt @@ -64,9 +64,9 @@ fun remainingLabel(item: BaseItem): String? { } /** - * The quiet line directly under the title: year, length, certificate. Deliberately short — - * the score sits beside the title and the genres are a credit row, so this stays at three - * items and never has to compete for the width. + * The quiet line directly under the title: year, length, certificate and, for a series, + * its production status. The score sits beside the title and the genres are a credit row, + * 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. */ @@ -78,6 +78,28 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List = buildList { item.runtimeMinutes?.let { add(formatRuntime(it)) } } 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 + } } /** diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt index fbec561..5d8d613 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt @@ -71,6 +71,28 @@ class ContinueWatchingResumeTest { 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 fun `pressed card resume point outranks a prefetched zero`() { assertEquals( diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/DetailItemStateTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/DetailItemStateTest.kt new file mode 100644 index 0000000..1317a5b --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/DetailItemStateTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt index b7c98d7..6522a2e 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt @@ -5,6 +5,7 @@ import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.ui.detail.availableSeasons import com.ponzischeme89.memby.ui.detail.defaultSeason 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.primaryActionLabel import com.ponzischeme89.memby.ui.detail.seasonLabel @@ -91,4 +92,56 @@ class SeriesDetailsTest { assertEquals("Specials", seasonLabel(0)) 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)) + } } diff --git a/server/README.md b/server/README.md index c8daa99..99d0e82 100644 --- a/server/README.md +++ b/server/README.md @@ -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 `minutes` filter enough headroom. `/v1/for-you` reads up to 240 ranked candidates and -derives several de-duplicated shelves: top picks, up to two distinct “Because you -finished …” shelves, up to two genre shelves, and a television-compatible shelf when -there is enough playback evidence. Completed-title evidence is distributed -deterministically across relevant candidates rather than allowing the newest Drama -title to explain every Drama recommendation. +derives several de-duplicated shelves: top picks, up to two distinct title-based shelves, +up to two theme or genre shelves, and a television-compatible shelf when there is enough +playback evidence. Reasons prefer defensible title relationships, specific themes and +viewing eras, then cast or creator matches; a top-level genre such as Drama is only a +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 stay frequent but do not rebuild pools. A session's first transition to stopped/completed diff --git a/server/internal/foryou/service.go b/server/internal/foryou/service.go index 6de5d93..28e2f84 100644 --- a/server/internal/foryou/service.go +++ b/server/internal/foryou/service.go @@ -33,7 +33,7 @@ const ( maxPreparedCandidates = 750 // Increment only when stored eligibility, scoring, or explanation behavior changes. - preparedAlgorithmVersion = "2026-07-31.2" + preparedAlgorithmVersion = "2026-08-23.1" ) type ImportResult struct { @@ -676,7 +676,14 @@ func buildPreparedRows( "for-you:because:", func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID }, func(item store.PreparedForYouItem) string { - return "Because you finished " + item.ReasonSourceTitle + switch item.ReasonKind { + case "completed-title": + return "Because you finished " + item.ReasonSourceTitle + case "favourite-title": + return "Because you like " + item.ReasonSourceTitle + default: + return "Because you watched " + item.ReasonSourceTitle + } }, 2, func(item store.PreparedForYouItem) bool { diff --git a/server/internal/foryou/service_test.go b/server/internal/foryou/service_test.go index 75a2f84..c86ea9a 100644 --- a/server/internal/foryou/service_test.go +++ b/server/internal/foryou/service_test.go @@ -214,6 +214,7 @@ func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) CompatibilityScore: 0.8, CompatibilityLabel: "Direct plays well on this TV", RecommendationReason: "Because you finished " + sourceTitle, + ReasonKind: "completed-title", ReasonGenre: genre, ReasonSourceItemID: sourceID, ReasonSourceTitle: sourceTitle, }) diff --git a/server/internal/recommend/engine.go b/server/internal/recommend/engine.go index b0a69a1..33794a5 100644 --- a/server/internal/recommend/engine.go +++ b/server/internal/recommend/engine.go @@ -444,28 +444,11 @@ func explainRecommendation( compatibility compatibilityProfile, browsed bool, ) (string, string) { - top := profile.TopGenres(5) - matched := "" - for _, wanted := range top { - for _, genre := range item.Genres { - if strings.EqualFold(wanted, genre) { - matched = genre - break - } - } - if matched != "" { - break - } - } reasons := make([]string, 0, 3) if browsed { reasons = append(reasons, "You explored this recently") - } else if matched != "" { - reasons = append(reasons, "Matches your "+matched+" viewing") - } else if len(profile.Seeds) > 0 { - reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name) } else { - reasons = append(reasons, "Matches your recent viewing") + reasons = append(reasons, strongestPersonalReason(profile, item).Text) } if availableMinutes > 0 && item.RuntimeMinutes() > 0 { 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, Title: definition.Title, Kind: definition.Kind, - Items: Raws(items), + Items: enrichRecommendationReasons(profile, items), }) } return rows @@ -1054,11 +1037,17 @@ func (e *Engine) similarRow( return Row{}, false } 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{ ID: "similar:" + seed.ID, Title: "Because you watched " + seed.Name, Kind: "similar", - Items: Raws(items), + Items: raws, }, true } @@ -1208,7 +1197,7 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile ID: "recommended", Title: "Recommended from your watching history", Kind: "recommended", - Items: Raws(items), + Items: enrichRecommendationReasons(profile, items), }, true } @@ -1240,6 +1229,16 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile ID: "recommended", Title: "Recommended from your watching history", Kind: "recommended", - Items: Raws(items), + Items: enrichRecommendationReasons(profile, items), }, 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 +} diff --git a/server/internal/recommend/engine_test.go b/server/internal/recommend/engine_test.go index 7e2cb41..aec0354 100644 --- a/server/internal/recommend/engine_test.go +++ b/server/internal/recommend/engine_test.go @@ -248,6 +248,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) { if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") { 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] if last.Kind != "recommended" || last.Title != "Recommended from your watching history" { t.Fatalf("unexpected history row: %+v", last) @@ -255,6 +258,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) { if last.ID != "recommended" { 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) { @@ -485,7 +491,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) { evidence := map[string][]PreparedEvidence{ "drama": {{ ItemID: "arrival", Title: "Arrival", - Genres: []string{"Drama", "Science Fiction"}, + Genres: []string{"Drama", "Science Fiction"}, Completed: true, }}, } counts := map[string]int{} @@ -503,7 +509,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) { if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 { t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"]) } - if kinds["genre"] == 0 { + if kinds["theme"] == 0 { t.Fatalf("reason kinds were not mixed: %+v", kinds) } } diff --git a/server/internal/recommend/explain.go b/server/internal/recommend/explain.go index 11260b1..2d84024 100644 --- a/server/internal/recommend/explain.go +++ b/server/internal/recommend/explain.go @@ -7,63 +7,288 @@ import ( "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 - -// 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 decadeReasonFloor = 1.5 -// Why returns up to limit short phrases explaining the item to this viewer, strongest -// first. Never nil: a profile with nothing in it still yields the catalogue facts. +type personalReason struct { + 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 { if limit <= 0 { limit = ReasonLimit } reasons := make([]string, 0, limit) - add := func(reason string) { - if len(reasons) < limit && reason != "" { - reasons = append(reasons, reason) + for _, candidate := range personalReasons(profile, item) { + if len(reasons) == limit { + return reasons } + reasons = append(reasons, candidate.Text) } - - if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor { - add("Because you watch " + genre) + if len(reasons) < limit && item.CommunityRating >= 7.5 { + reasons = append(reasons, "Well rated ("+ + strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64)+")") } - if name, weight := heaviestPerson(profile, item); weight >= reasonFloor { - add("You've watched " + name + " before") + if len(reasons) < limit && item.ProductionYear > 0 && + time.Now().Year()-item.ProductionYear <= 1 { + reasons = append(reasons, "A recent release") } - if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor { - add("More from " + studio) - } - - // 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") + if len(reasons) == 0 { + reasons = append(reasons, "Recommended from your library") } return reasons } -// heaviest picks the wanted key with the most weight behind it, matched case-insensitively -// because Emby's own tagging is not consistent about it. Ties break alphabetically so the -// same profile and item always produce the same sentence. +func strongestPersonalReason(profile Profile, item Item) personalReason { + if reasons := personalReasons(profile, item); len(reasons) > 0 { + 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) { best, bestWeight := "", 0.0 for _, candidate := range wanted { @@ -72,32 +297,17 @@ func heaviest(weights map[string]float64, wanted []string) (string, float64) { continue } weight := weightFold(weights, candidate) - if weight <= 0 { - continue - } - if weight > bestWeight || (weight == bestWeight && candidate < best) { + if weight > bestWeight || weight == bestWeight && weight > 0 && candidate < best { best, bestWeight = candidate, weight } } 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 { 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 { type kv struct { name string diff --git a/server/internal/recommend/explain_test.go b/server/internal/recommend/explain_test.go index dbd1f9a..a6d2e50 100644 --- a/server/internal/recommend/explain_test.go +++ b/server/internal/recommend/explain_test.go @@ -30,17 +30,17 @@ const thrillerHistory = `{ "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) 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"}] }`) reasons := Why(profile, candidate, ReasonLimit) - if len(reasons) == 0 || reasons[0] != "Because you watch Thriller" { - t.Fatalf("expected the genre reason first, got %v", reasons) + if len(reasons) == 0 || reasons[0] != "Because you watched Sicario" { + t.Fatalf("expected the related viewing reason first, got %v", reasons) } if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") { 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) - 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) } } @@ -98,9 +98,9 @@ func TestWhyIsCappedAndOrdered(t *testing.T) { t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons) } want := []string{ - "Because you watch Thriller", - "You've watched Emily Blunt before", - "More from Lionsgate", + "Because you watched Sicario", + "Because you like crime thrillers", + "Because you watch Emily Blunt", } for i, reason := range want { 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) { profile := explainProfile(t, thrillerHistory) candidate := explainItem(t, `{ diff --git a/server/internal/recommend/prepared.go b/server/internal/recommend/prepared.go index 19126df..2df713b 100644 --- a/server/internal/recommend/prepared.go +++ b/server/internal/recommend/prepared.go @@ -25,6 +25,7 @@ type PreparedEvidence struct { ItemID string `json:"itemId"` Title string `json:"title"` Genres []string `json:"genres,omitempty"` + Completed bool `json:"completed,omitempty"` } type PreparedTitleAffinity struct { @@ -128,7 +129,7 @@ func (e *Engine) PrepareForYou( index := newCatalogueIndex(catalogue) evidenceByGenre := map[string][]PreparedEvidence{} evidenceSeen := map[string]bool{} - addCompletedEvidence := func(item Item, sessionID string) { + addCompletedEvidence := func(item Item, sessionID string, completed bool) { itemID := item.ID title := item.Name if strings.EqualFold(item.Type, "Episode") && @@ -152,6 +153,7 @@ func (e *Engine) PrepareForYou( ItemID: itemID, Title: title, 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. for _, item := range history { if item.UserData.Played { - addCompletedEvidence(item, "") + addCompletedEvidence(item, "", !strings.EqualFold(item.Type, "Episode")) } } titleAffinity := map[string]PreparedTitleAffinity{} @@ -263,7 +265,9 @@ func (e *Engine) PrepareForYou( } if completion >= 0.9 { - addCompletedEvidence(item, session.ID) + addCompletedEvidence( + item, session.ID, !strings.EqualFold(session.MediaType, "episode"), + ) } } @@ -559,49 +563,46 @@ func explainPreparedRecommendation( evidenceByGenre map[string][]PreparedEvidence, completedReasonCounts map[string]int, ) (reason, label, kind, genre string, evidence PreparedEvidence) { - for _, wanted := range profile.TopGenres(5) { - for _, candidateGenre := range item.Genres { - if strings.EqualFold(wanted, candidateGenre) { - genre = candidateGenre - options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))] - strong := make([]PreparedEvidence, 0, len(options)) - for _, option := range options { - if strongEvidenceMatch(item, option) { - strong = append(strong, option) - } - } - if len(strong) > 0 { - evidence = strong[stableEvidenceIndex(item.ID, len(strong))] - } - break + strong := make([]PreparedEvidence, 0) + seenEvidence := map[string]bool{} + for _, candidateGenre := range item.Genres { + options := evidenceByGenre[strings.ToLower(strings.TrimSpace(candidateGenre))] + for _, option := range options { + if seenEvidence[option.ItemID] || !strongEvidenceMatch(item, option) { + continue } - } - if genre != "" { - break + seenEvidence[option.ItemID] = true + strong = append(strong, option) } } - // 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 + if len(strong) > 0 { + evidence = strong[stableEvidenceIndex(item.ID, len(strong))] + } + useTitle := evidence.Title != "" && completedReasonCounts[evidence.ItemID] < 4 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: reason, kind = "You explored this recently", "browsed" 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: - reason, kind = "Matches your recent viewing", "generic" - evidence = PreparedEvidence{} + 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{} + } } switch score := compatibilityScore(item, compatibility); { case score > 0.2: @@ -615,24 +616,22 @@ func explainPreparedRecommendation( return reason, label, kind, genre, evidence } -func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool { - shared := 0 - broadOnly := true - 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 +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 shared >= 2 || shared == 1 && !broadOnly + return personalReason{Text: "Recommended from your library", Kind: "generic"} +} + +func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool { + shared, specific := sharedGenreCount(item.Genres, evidence.Genres) + return shared >= 2 || specific >= 1 } func stableEvidenceIndex(itemID string, size int) int { diff --git a/server/internal/recommend/profile.go b/server/internal/recommend/profile.go index 6f1f265..e8e3bc3 100644 --- a/server/internal/recommend/profile.go +++ b/server/internal/recommend/profile.go @@ -109,6 +109,11 @@ type Profile struct { // 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. 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 // recommendation never suggests something the user is already partway through. Seen map[string]bool @@ -116,6 +121,11 @@ type Profile struct { Seeds []Seed } +type ReasonEvidence struct { + Item Item + Favourite bool +} + func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 } // Decode parses raw Emby items, keeping the original payload attached. @@ -141,14 +151,26 @@ func BuildProfile(history, favorites []Item) Profile { GenreWeights: map[string]float64{}, StudioWeights: map[string]float64{}, PersonWeights: map[string]float64{}, + DecadeWeights: map[int]float64{}, Seen: map[string]bool{}, SeenTitles: map[string]bool{}, } seedSeen := map[string]bool{} tasteSeen := map[string]bool{} + reasonSeen := map[string]bool{} for i, item := range history { 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 // 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 { 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 } @@ -223,6 +254,12 @@ func (p *Profile) absorbTaste(item Item, weight float64) { 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