0.3.06
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
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,
|
2,
|
||||||
func(item store.PreparedForYouItem) bool {
|
func(item store.PreparedForYouItem) bool {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, `{
|
||||||
|
|||||||
@@ -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,49 +563,46 @@ 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)
|
||||||
for _, candidateGenre := range item.Genres {
|
seenEvidence := map[string]bool{}
|
||||||
if strings.EqualFold(wanted, candidateGenre) {
|
for _, candidateGenre := range item.Genres {
|
||||||
genre = candidateGenre
|
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(candidateGenre))]
|
||||||
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
|
for _, option := range options {
|
||||||
strong := make([]PreparedEvidence, 0, len(options))
|
if seenEvidence[option.ItemID] || !strongEvidenceMatch(item, option) {
|
||||||
for _, option := range options {
|
continue
|
||||||
if strongEvidenceMatch(item, option) {
|
|
||||||
strong = append(strong, option)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(strong) > 0 {
|
|
||||||
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
seenEvidence[option.ItemID] = true
|
||||||
if genre != "" {
|
strong = append(strong, option)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep specific evidence prominent without letting it monopolise a row. One third
|
if len(strong) > 0 {
|
||||||
// of otherwise eligible cards deliberately uses the broader genre explanation,
|
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
|
||||||
// and no completed title can explain more than four candidates in a prepared pool.
|
}
|
||||||
useCompleted := evidence.Title != "" &&
|
useTitle := evidence.Title != "" && completedReasonCounts[evidence.ItemID] < 4
|
||||||
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)
|
||||||
evidence = PreparedEvidence{}
|
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); {
|
switch score := compatibilityScore(item, compatibility); {
|
||||||
case score > 0.2:
|
case score > 0.2:
|
||||||
@@ -615,24 +616,22 @@ func explainPreparedRecommendation(
|
|||||||
return reason, label, kind, genre, evidence
|
return reason, label, kind, genre, evidence
|
||||||
}
|
}
|
||||||
|
|
||||||
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
|
func strongestAvailableReason(
|
||||||
shared := 0
|
profile Profile,
|
||||||
broadOnly := true
|
item Item,
|
||||||
for _, candidateGenre := range item.Genres {
|
titleReasonCounts map[string]int,
|
||||||
for _, evidenceGenre := range evidence.Genres {
|
) personalReason {
|
||||||
if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
|
for _, reason := range personalReasons(profile, item) {
|
||||||
continue
|
if reason.SourceID == "" || titleReasonCounts[reason.SourceID] < 4 {
|
||||||
}
|
return reason
|
||||||
shared++
|
|
||||||
switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
|
|
||||||
case "action", "adventure", "comedy", "drama", "thriller":
|
|
||||||
default:
|
|
||||||
broadOnly = false
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
func stableEvidenceIndex(itemID string, size int) int {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user