0.2.54
This commit is contained in:
@@ -42,7 +42,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.53"
|
val defaultVersionName = "0.2.54"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
|
|
||||||
/** True when the backend can return the whole home screen in one request. */
|
/** True when the backend can return the whole home screen in one request. */
|
||||||
val supportsBatchHome: Boolean get() = ServerConfig.isGateway
|
val supportsBatchHome: Boolean get() = ServerConfig.isGateway
|
||||||
|
val supportsMediaRequests: Boolean get() = ServerConfig.isGateway
|
||||||
|
|
||||||
private fun apiFor(serverUrl: String): EmbyApi {
|
private fun apiFor(serverUrl: String): EmbyApi {
|
||||||
val base = normalizeServerUrl(serverUrl)
|
val base = normalizeServerUrl(serverUrl)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import java.util.UUID
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds one ordered, user-scoped foreground journey. Callers provide controlled labels;
|
* Builds one ordered, user-scoped foreground journey. Callers provide controlled labels;
|
||||||
* this collector has no field for titles, queries, setting values or arbitrary metadata.
|
* Item names are retained so the operator can recognise what fired an event. Search
|
||||||
|
* queries, setting values and arbitrary metadata remain outside this collector.
|
||||||
*/
|
*/
|
||||||
class JourneyAnalytics(
|
class JourneyAnalytics(
|
||||||
private val userId: String,
|
private val userId: String,
|
||||||
@@ -30,7 +31,7 @@ class JourneyAnalytics(
|
|||||||
feature: String = "",
|
feature: String = "",
|
||||||
source: String = "",
|
source: String = "",
|
||||||
target: String = "",
|
target: String = "",
|
||||||
itemId: String = "",
|
itemName: String = "",
|
||||||
itemType: String = "",
|
itemType: String = "",
|
||||||
outcome: String = "",
|
outcome: String = "",
|
||||||
) = synchronized(lock) {
|
) = synchronized(lock) {
|
||||||
@@ -45,7 +46,7 @@ class JourneyAnalytics(
|
|||||||
feature = clean(feature),
|
feature = clean(feature),
|
||||||
source = clean(source),
|
source = clean(source),
|
||||||
target = clean(target),
|
target = clean(target),
|
||||||
itemId = clean(itemId),
|
itemName = cleanName(itemName),
|
||||||
itemType = clean(itemType),
|
itemType = clean(itemType),
|
||||||
outcome = clean(outcome),
|
outcome = clean(outcome),
|
||||||
occurredAt = timestamp(),
|
occurredAt = timestamp(),
|
||||||
@@ -68,6 +69,8 @@ class JourneyAnalytics(
|
|||||||
it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == ':'
|
it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == ':'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cleanName(value: String): String = value.trim().take(160).filterNot(Char::isISOControl)
|
||||||
|
|
||||||
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
|
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ data class GatewayRequestCandidate(
|
|||||||
val overview: String = "",
|
val overview: String = "",
|
||||||
val posterUrl: String = "",
|
val posterUrl: String = "",
|
||||||
val alreadyAdded: Boolean = false,
|
val alreadyAdded: Boolean = false,
|
||||||
|
val inLibrary: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -736,7 +737,7 @@ data class GatewayJourneyEvent(
|
|||||||
val feature: String = "",
|
val feature: String = "",
|
||||||
val source: String = "",
|
val source: String = "",
|
||||||
val target: String = "",
|
val target: String = "",
|
||||||
val itemId: String = "",
|
val itemName: String = "",
|
||||||
val itemType: String = "",
|
val itemType: String = "",
|
||||||
val outcome: String = "",
|
val outcome: String = "",
|
||||||
val occurredAt: String = "",
|
val occurredAt: String = "",
|
||||||
|
|||||||
@@ -373,21 +373,6 @@ private fun FeaturedMovieCard(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Play is measured before the words, and that is the whole layout.
|
|
||||||
//
|
|
||||||
// This column is the height of a fixed-height card. A Column hands each child
|
|
||||||
// the height left after the ones before it, so the chip — being last — was
|
|
||||||
// given whatever a two-line title had not already taken, and rendered as a
|
|
||||||
// green sliver with its label squeezed out of it. Not clipped: *compressed*,
|
|
||||||
// which is why it looked malformed rather than missing.
|
|
||||||
//
|
|
||||||
// Putting the text in a `weight(1f, fill = false)` child inverts the order:
|
|
||||||
// weighted children are measured from what is left over, so the spacer and the
|
|
||||||
// chip take their natural size first and the prose is what gives way. The
|
|
||||||
// synopsis still stands down on its own when the title wraps, so in practice
|
|
||||||
// nothing has to be cut at all — but the button can no longer be the thing that
|
|
||||||
// pays for a long title, whatever the artwork, the ratings strip or the
|
|
||||||
// viewport do.
|
|
||||||
var titleLines by remember(item.id) { mutableIntStateOf(1) }
|
var titleLines by remember(item.id) { mutableIntStateOf(1) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -472,8 +457,6 @@ private fun FeaturedMovieCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(14.dp))
|
|
||||||
MembyPlayChip(label = "Play", focused = focused)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,9 +207,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
|
|
||||||
fun trackJourney(
|
fun trackJourney(
|
||||||
category: String, action: String, screen: String = "", feature: String = "",
|
category: String, action: String, screen: String = "", feature: String = "",
|
||||||
source: String = "", target: String = "", itemId: String = "", itemType: String = "",
|
source: String = "", target: String = "", itemName: String = "", itemType: String = "",
|
||||||
outcome: String = "",
|
outcome: String = "",
|
||||||
) = journey.track(category, action, screen, feature, source, target, itemId, itemType, outcome)
|
) = journey.track(category, action, screen, feature, source, target, itemName, itemType, outcome)
|
||||||
|
|
||||||
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
|
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
|
||||||
|
|
||||||
|
|||||||
@@ -2129,7 +2129,7 @@ private fun HomeScreen(
|
|||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
|
category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
|
||||||
feature = "playback", source = returnRowId.orEmpty(), target = "player",
|
feature = "playback", source = returnRowId.orEmpty(), target = "player",
|
||||||
itemId = item.id, itemType = item.type,
|
itemName = item.name, itemType = item.type,
|
||||||
)
|
)
|
||||||
homeViewModel.flushAnalytics()
|
homeViewModel.flushAnalytics()
|
||||||
launchingItem = item
|
launchingItem = item
|
||||||
@@ -2490,7 +2490,7 @@ private fun HomeScreen(
|
|||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "content", action = "open", screen = "search",
|
category = "content", action = "open", screen = "search",
|
||||||
feature = "search", source = "search_results", target = "details",
|
feature = "search", source = "search_results", target = "details",
|
||||||
itemId = item.id, itemType = item.type,
|
itemName = item.name, itemType = item.type,
|
||||||
)
|
)
|
||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
detailsItem = item
|
detailsItem = item
|
||||||
@@ -2520,7 +2520,7 @@ private fun HomeScreen(
|
|||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "content", action = "open", screen = "calendar",
|
category = "content", action = "open", screen = "calendar",
|
||||||
feature = "tv_calendar", source = "calendar_day",
|
feature = "tv_calendar", source = "calendar_day",
|
||||||
target = "details", itemId = item.id, itemType = item.type,
|
target = "details", itemName = item.name, itemType = item.type,
|
||||||
)
|
)
|
||||||
// The same substitution the schedule row makes: a calendar card
|
// The same substitution the schedule row makes: a calendar card
|
||||||
// is an episode that has not aired, so what was asked for is the
|
// is an episode that has not aired, so what was asked for is the
|
||||||
@@ -2567,7 +2567,7 @@ private fun HomeScreen(
|
|||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "content", action = "open", screen = "genre_browser",
|
category = "content", action = "open", screen = "genre_browser",
|
||||||
feature = "genre_browse", source = "genre_results", target = "details",
|
feature = "genre_browse", source = "genre_results", target = "details",
|
||||||
itemId = item.id, itemType = item.type,
|
itemName = item.name, itemType = item.type,
|
||||||
)
|
)
|
||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
detailsItem = item
|
detailsItem = item
|
||||||
@@ -2709,7 +2709,13 @@ private fun HomeScreen(
|
|||||||
returnRowId = HOME_HERO_ROW_ID
|
returnRowId = HOME_HERO_ROW_ID
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
playItem(item)
|
homeViewModel.trackJourney(
|
||||||
|
category = "content", action = "open", screen = "home",
|
||||||
|
feature = "hero", source = HOME_HERO_ROW_ID, target = "details",
|
||||||
|
itemName = item.name, itemType = item.type,
|
||||||
|
)
|
||||||
|
detailsAiringNotice = null
|
||||||
|
detailsItem = item
|
||||||
},
|
},
|
||||||
modifier = Modifier.height(metadataHeight),
|
modifier = Modifier.height(metadataHeight),
|
||||||
)
|
)
|
||||||
@@ -2800,7 +2806,7 @@ private fun HomeScreen(
|
|||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "content", action = "open", screen = "shows",
|
category = "content", action = "open", screen = "shows",
|
||||||
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
||||||
itemId = it.itemId,
|
itemName = it.title,
|
||||||
)
|
)
|
||||||
myShowReturnItemId = it.itemId
|
myShowReturnItemId = it.itemId
|
||||||
selectedMyShow = it
|
selectedMyShow = it
|
||||||
@@ -2971,7 +2977,7 @@ private fun HomeScreen(
|
|||||||
category = "content", action = "open",
|
category = "content", action = "open",
|
||||||
screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(),
|
screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(),
|
||||||
source = row.id, target = if (item.membyPlayable) "details" else "content_action",
|
source = row.id, target = if (item.membyPlayable) "details" else "content_action",
|
||||||
itemId = item.id, itemType = item.type,
|
itemName = item.name, itemType = item.type,
|
||||||
)
|
)
|
||||||
// A schedule card is an episode that has not aired, so
|
// A schedule card is an episode that has not aired, so
|
||||||
// it is not playable and has no page of its own. What
|
// it is not playable and has no page of its own. What
|
||||||
@@ -3223,7 +3229,7 @@ private fun HomeScreen(
|
|||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "recommendations", action = "open", screen = "details",
|
category = "recommendations", action = "open", screen = "details",
|
||||||
feature = "related", source = "related", target = "details",
|
feature = "related", source = "related", target = "details",
|
||||||
itemId = related.id, itemType = related.type,
|
itemName = related.name, itemType = related.type,
|
||||||
)
|
)
|
||||||
detailsTrail = detailsTrail + selected
|
detailsTrail = detailsTrail + selected
|
||||||
restoreDetailPosition = false
|
restoreDetailPosition = false
|
||||||
@@ -3249,7 +3255,7 @@ private fun HomeScreen(
|
|||||||
onToggleFavorite = { item, saved ->
|
onToggleFavorite = { item, saved ->
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "library", action = if (saved) "favourite" else "unfavourite",
|
category = "library", action = if (saved) "favourite" else "unfavourite",
|
||||||
screen = "details", feature = "favorites", itemId = item.id, itemType = item.type,
|
screen = "details", feature = "favorites", itemName = item.name, itemType = item.type,
|
||||||
outcome = "success",
|
outcome = "success",
|
||||||
)
|
)
|
||||||
homeViewModel.setFavorite(item, saved)
|
homeViewModel.setFavorite(item, saved)
|
||||||
@@ -3258,7 +3264,7 @@ private fun HomeScreen(
|
|||||||
onToggleMyShow = { item, saved ->
|
onToggleMyShow = { item, saved ->
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "library", action = if (saved) "follow" else "unfollow",
|
category = "library", action = if (saved) "follow" else "unfollow",
|
||||||
screen = "details", feature = "my_shows", itemId = item.id, itemType = item.type,
|
screen = "details", feature = "my_shows", itemName = item.name, itemType = item.type,
|
||||||
outcome = "success",
|
outcome = "success",
|
||||||
)
|
)
|
||||||
// Optimistic, the way a favourite already is. Following a show is a
|
// Optimistic, the way a favourite already is. Following a show is a
|
||||||
@@ -3297,7 +3303,7 @@ private fun HomeScreen(
|
|||||||
onTogglePlayed = { item, played ->
|
onTogglePlayed = { item, played ->
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "library", action = if (played) "mark_played" else "mark_unplayed",
|
category = "library", action = if (played) "mark_played" else "mark_unplayed",
|
||||||
screen = "details", feature = "played_status", itemId = item.id, itemType = item.type,
|
screen = "details", feature = "played_status", itemName = item.name, itemType = item.type,
|
||||||
outcome = "success",
|
outcome = "success",
|
||||||
)
|
)
|
||||||
homeViewModel.setPlayed(item, played)
|
homeViewModel.setPlayed(item, played)
|
||||||
|
|||||||
@@ -213,7 +213,8 @@ fun SearchScreen(
|
|||||||
state.errorMessage != null && state.results.isEmpty() -> true
|
state.errorMessage != null && state.results.isEmpty() -> true
|
||||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
||||||
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty()
|
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() ||
|
||||||
|
(state.requestsAvailable && shouldSearch(state.query))
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||||
@@ -251,6 +252,7 @@ fun SearchScreen(
|
|||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
when {
|
when {
|
||||||
|
state.requestMode -> viewModel.hideRequests()
|
||||||
// A genre is its own level, between discovery and leaving Search. Close that
|
// A genre is its own level, between discovery and leaving Search. Close that
|
||||||
// level first regardless of which card currently owns focus.
|
// level first regardless of which card currently owns focus.
|
||||||
state.genre != null -> closeGenre()
|
state.genre != null -> closeGenre()
|
||||||
@@ -319,6 +321,7 @@ fun SearchScreen(
|
|||||||
onItemSelected = onItemSelected,
|
onItemSelected = onItemSelected,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onRequest = viewModel::request,
|
onRequest = viewModel::request,
|
||||||
|
onShowRequests = viewModel::showRequests,
|
||||||
onSuggestionSelected = viewModel::onQueryChanged,
|
onSuggestionSelected = viewModel::onQueryChanged,
|
||||||
onGenreSelected = viewModel::onGenreSelected,
|
onGenreSelected = viewModel::onGenreSelected,
|
||||||
onBackFromGenre = closeGenre,
|
onBackFromGenre = closeGenre,
|
||||||
@@ -443,7 +446,7 @@ private fun QueryField(
|
|||||||
color = if (query.isEmpty()) Muted else Heading,
|
color = if (query.isEmpty()) Muted else Heading,
|
||||||
// Fixed size rather than shrinking as the query grows: readable at three
|
// Fixed size rather than shrinking as the query grows: readable at three
|
||||||
// metres matters more than fitting a long query on one line.
|
// metres matters more than fitting a long query on one line.
|
||||||
fontSize = if (query.isEmpty()) 15.sp else 17.sp,
|
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
|
||||||
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
||||||
@@ -701,6 +704,7 @@ private fun ResultsPane(
|
|||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||||
|
onShowRequests: () -> Unit,
|
||||||
onSuggestionSelected: (String) -> Unit,
|
onSuggestionSelected: (String) -> Unit,
|
||||||
onGenreSelected: (String) -> Unit,
|
onGenreSelected: (String) -> Unit,
|
||||||
onBackFromGenre: () -> Unit,
|
onBackFromGenre: () -> Unit,
|
||||||
@@ -726,6 +730,9 @@ private fun ResultsPane(
|
|||||||
state = state,
|
state = state,
|
||||||
showingDiscovery = showingDiscovery,
|
showingDiscovery = showingDiscovery,
|
||||||
onBackFromGenre = onBackFromGenre,
|
onBackFromGenre = onBackFromGenre,
|
||||||
|
onShowRequests = onShowRequests,
|
||||||
|
requestFocusRequester = resultsEntry,
|
||||||
|
keyboardReturn = keyboardReturn,
|
||||||
)
|
)
|
||||||
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
|
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
|
||||||
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
||||||
@@ -767,7 +774,7 @@ private fun ResultsPane(
|
|||||||
resultsEntry = resultsEntry,
|
resultsEntry = resultsEntry,
|
||||||
keyboardReturn = keyboardReturn,
|
keyboardReturn = keyboardReturn,
|
||||||
)
|
)
|
||||||
!showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() ->
|
!showingDiscovery && state.requestMode ->
|
||||||
RequestOptions(
|
RequestOptions(
|
||||||
state = state,
|
state = state,
|
||||||
resultsEntry = resultsEntry,
|
resultsEntry = resultsEntry,
|
||||||
@@ -805,6 +812,9 @@ private fun ResultsHeading(
|
|||||||
state: SearchUiState,
|
state: SearchUiState,
|
||||||
showingDiscovery: Boolean,
|
showingDiscovery: Boolean,
|
||||||
onBackFromGenre: () -> Unit,
|
onBackFromGenre: () -> Unit,
|
||||||
|
onShowRequests: () -> Unit,
|
||||||
|
requestFocusRequester: FocusRequester,
|
||||||
|
keyboardReturn: FocusRequester,
|
||||||
) {
|
) {
|
||||||
val title = when {
|
val title = when {
|
||||||
showingDiscovery -> "Browse your library"
|
showingDiscovery -> "Browse your library"
|
||||||
@@ -854,6 +864,17 @@ private fun ResultsHeading(
|
|||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
|
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
|
||||||
}
|
}
|
||||||
|
if (state.requestsAvailable && state.genre == null && !showingDiscovery && !state.requestMode) {
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
MembyChoiceChip(
|
||||||
|
label = "Request",
|
||||||
|
selected = false,
|
||||||
|
onClick = onShowRequests,
|
||||||
|
modifier = Modifier
|
||||||
|
.then(if (state.results.isEmpty()) Modifier.focusRequester(requestFocusRequester) else Modifier)
|
||||||
|
.focusProperties { left = keyboardReturn },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1029,7 @@ private fun RequestOptions(
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||||
Text(
|
Text(
|
||||||
"We found a few close matches. Choose the exact movie or series you want added.",
|
"Search Radarr and Sonarr, then choose the exact movie or series you want added.",
|
||||||
color = Muted,
|
color = Muted,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@@ -1022,7 +1043,7 @@ private fun RequestOptions(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
LazyVerticalGrid(
|
if (state.requestCandidates.isNotEmpty()) LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(2),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -1043,6 +1064,15 @@ private fun RequestOptions(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
if (state.requestLookupLoading) "Finding movies and series…" else "No request matches found.",
|
||||||
|
color = Muted,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
state.requestMessage?.let { message ->
|
state.requestMessage?.let { message ->
|
||||||
val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent
|
val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@@ -1077,13 +1107,14 @@ private fun RequestCandidateCard(
|
|||||||
) {
|
) {
|
||||||
val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES"
|
val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES"
|
||||||
val actionLabel = when {
|
val actionLabel = when {
|
||||||
|
candidate.inLibrary -> "IN LIBRARY"
|
||||||
candidate.alreadyAdded -> "REQUESTED"
|
candidate.alreadyAdded -> "REQUESTED"
|
||||||
busy -> "REQUESTING…"
|
busy -> "REQUESTING…"
|
||||||
else -> "REQUEST"
|
else -> "REQUEST"
|
||||||
}
|
}
|
||||||
FocusScaleContainer(
|
FocusScaleContainer(
|
||||||
onFocused = {},
|
onFocused = {},
|
||||||
onClick = { if (!candidate.alreadyAdded && !busy) onRequest() },
|
onClick = { if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) onRequest() },
|
||||||
contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
|
contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) { focused ->
|
) { focused ->
|
||||||
@@ -1095,7 +1126,7 @@ private fun RequestCandidateCard(
|
|||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
focused -> MembyAccentMuted
|
focused -> MembyAccentMuted
|
||||||
candidate.alreadyAdded -> MembyAccentMuted
|
candidate.inLibrary || candidate.alreadyAdded -> MembyAccentMuted
|
||||||
else -> MembyControlSurface
|
else -> MembyControlSurface
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1103,7 +1134,7 @@ private fun RequestCandidateCard(
|
|||||||
width = if (focused) 2.dp else 1.dp,
|
width = if (focused) 2.dp else 1.dp,
|
||||||
color = when {
|
color = when {
|
||||||
focused -> Accent
|
focused -> Accent
|
||||||
candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
|
candidate.inLibrary || candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
|
||||||
else -> Color.White.copy(alpha = 0.10f)
|
else -> Color.White.copy(alpha = 0.10f)
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
@@ -1169,17 +1200,19 @@ private fun RequestCandidateCard(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (candidate.alreadyAdded) {
|
if (candidate.inLibrary || candidate.alreadyAdded) {
|
||||||
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
|
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
|
||||||
Spacer(Modifier.width(5.dp))
|
Spacer(Modifier.width(5.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
actionLabel,
|
actionLabel,
|
||||||
color = if (candidate.alreadyAdded || focused) Accent else KeyLabel,
|
color = if (candidate.inLibrary || candidate.alreadyAdded || focused) Accent else KeyLabel,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
if (!candidate.alreadyAdded && !busy) Text(" →", color = Accent, fontSize = 13.sp)
|
if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) {
|
||||||
|
Text(" →", color = Accent, fontSize = 13.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ data class SearchUiState(
|
|||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val pagingErrorMessage: String? = null,
|
val pagingErrorMessage: String? = null,
|
||||||
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
|
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
|
||||||
|
val requestsAvailable: Boolean = false,
|
||||||
|
val requestMode: Boolean = false,
|
||||||
val requestLookupLoading: Boolean = false,
|
val requestLookupLoading: Boolean = false,
|
||||||
val requestingCandidateKey: String? = null,
|
val requestingCandidateKey: String? = null,
|
||||||
val requestMessage: String? = null,
|
val requestMessage: String? = null,
|
||||||
@@ -91,7 +93,9 @@ data class SearchUiState(
|
|||||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(SearchUiState())
|
private val _state = MutableStateFlow(
|
||||||
|
SearchUiState(requestsAvailable = repository.supportsMediaRequests),
|
||||||
|
)
|
||||||
val state: StateFlow<SearchUiState> = _state.asStateFlow()
|
val state: StateFlow<SearchUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -176,6 +180,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
query = "", results = emptyList(), genreOffset = 0,
|
query = "", results = emptyList(), genreOffset = 0,
|
||||||
isLoading = false, hasSearched = false,
|
isLoading = false, hasSearched = false,
|
||||||
errorMessage = null, requestCandidates = emptyList(),
|
errorMessage = null, requestCandidates = emptyList(),
|
||||||
|
requestMode = false,
|
||||||
pagingErrorMessage = null,
|
pagingErrorMessage = null,
|
||||||
requestLookupLoading = false, requestMessage = null,
|
requestLookupLoading = false, requestMessage = null,
|
||||||
requestMessageIsError = false, genre = null,
|
requestMessageIsError = false, genre = null,
|
||||||
@@ -199,6 +204,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
isLoading = true,
|
isLoading = true,
|
||||||
hasSearched = false, errorMessage = null, isLoadingMore = false,
|
hasSearched = false, errorMessage = null, isLoadingMore = false,
|
||||||
canLoadMore = false, requestCandidates = emptyList(),
|
canLoadMore = false, requestCandidates = emptyList(),
|
||||||
|
requestMode = false,
|
||||||
pagingErrorMessage = null,
|
pagingErrorMessage = null,
|
||||||
requestLookupLoading = false, requestMessage = null,
|
requestLookupLoading = false, requestMessage = null,
|
||||||
requestMessageIsError = false,
|
requestMessageIsError = false,
|
||||||
@@ -261,7 +267,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun request(candidate: GatewayRequestCandidate) {
|
fun request(candidate: GatewayRequestCandidate) {
|
||||||
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
|
if (candidate.alreadyAdded || candidate.inLibrary || state.value.requestingCandidateKey != null) return
|
||||||
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
|
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
|
||||||
val queryAtRequest = state.value.query.trim()
|
val queryAtRequest = state.value.query.trim()
|
||||||
val genreAtRequest = state.value.genre
|
val genreAtRequest = state.value.genre
|
||||||
@@ -304,6 +310,23 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showRequests() {
|
||||||
|
val term = state.value.query.trim()
|
||||||
|
if (!shouldSearch(term)) return
|
||||||
|
_state.update {
|
||||||
|
it.copy(requestMode = true, requestCandidates = emptyList(), requestMessage = null,
|
||||||
|
requestMessageIsError = false)
|
||||||
|
}
|
||||||
|
viewModelScope.launch { loadRequestCandidates(term) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideRequests() {
|
||||||
|
_state.update {
|
||||||
|
it.copy(requestMode = false, requestCandidates = emptyList(), requestLookupLoading = false,
|
||||||
|
requestMessage = null, requestMessageIsError = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Genre chips for the empty state, taken from items the home screen already loaded.
|
* Genre chips for the empty state, taken from items the home screen already loaded.
|
||||||
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
|
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
|
||||||
@@ -408,7 +431,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
_state.update {
|
_state.update {
|
||||||
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
||||||
}
|
}
|
||||||
if (cached.isEmpty()) loadRequestCandidates(term)
|
if (_state.value.requestMode) loadRequestCandidates(term)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +465,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
_state.update {
|
_state.update {
|
||||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||||
}
|
}
|
||||||
if (ranked.isEmpty()) loadRequestCandidates(term)
|
if (_state.value.requestMode) loadRequestCandidates(term)
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
// A cancelled search is the normal case while typing, not a failure.
|
// A cancelled search is the normal case while typing, not a failure.
|
||||||
@@ -457,10 +480,19 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadRequestCandidates(term: String) {
|
private suspend fun loadRequestCandidates(term: String) {
|
||||||
_state.update { it.copy(requestLookupLoading = true) }
|
_state.update { it.copy(requestLookupLoading = true, requestMessage = null) }
|
||||||
val candidates = runCatching { repository.lookupMediaRequests(term) }
|
val candidates = runCatching { repository.lookupMediaRequests(term) }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||||
|
if (_state.value.query.trim() == term) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
requestLookupLoading = false,
|
||||||
|
requestMessage = friendlyEmbyError(error),
|
||||||
|
requestMessageIsError = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
if (_state.value.query.trim() == term) {
|
if (_state.value.query.trim() == term) {
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ class JourneyAnalyticsTest {
|
|||||||
val analytics = JourneyAnalytics(userId = "user-1", now = { 1_700_000_000_000L }, journeyId = "journey-1")
|
val analytics = JourneyAnalytics(userId = "user-1", now = { 1_700_000_000_000L }, journeyId = "journey-1")
|
||||||
analytics.track(
|
analytics.track(
|
||||||
category = "content", action = "open", screen = "home screen",
|
category = "content", action = "open", screen = "home screen",
|
||||||
feature = "latest_movies", itemId = "item/unsafe", itemType = "Movie",
|
feature = "latest_movies", itemName = "Whale Rider", itemType = "Movie",
|
||||||
)
|
)
|
||||||
val events = analytics.drain()
|
val events = analytics.drain()
|
||||||
|
|
||||||
assertEquals(listOf(0, 1), events.map { it.sequence })
|
assertEquals(listOf(0, 1), events.map { it.sequence })
|
||||||
assertEquals("user-1", events.last().userId)
|
assertEquals("user-1", events.last().userId)
|
||||||
assertEquals("homescreen", events.last().screen)
|
assertEquals("homescreen", events.last().screen)
|
||||||
assertEquals("itemunsafe", events.last().itemId)
|
assertEquals("Whale Rider", events.last().itemName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -31,6 +31,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
|
|||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -57,8 +58,8 @@ class HomeMovieHeroScreenshotTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The case the audit reproduced: with a title long enough to wrap, the card's content
|
* The case the audit reproduced: with a title long enough to wrap, the card's content
|
||||||
* column overflowed its fixed height and the green Play chip — the last thing in the
|
* column used to overflow its fixed height around the individual Play chip. The hero
|
||||||
* column — was clipped away entirely. It has to survive here.
|
* now opens the listing page as one clear card, including for a wrapping title.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `home hero with a wrapping title`() {
|
fun `home hero with a wrapping title`() {
|
||||||
@@ -81,7 +82,7 @@ class HomeMovieHeroScreenshotTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The hero the gateway composes. Two things are only checkable by looking: the reason
|
* The hero the gateway composes. Two things are only checkable by looking: the reason
|
||||||
* takes the synopsis's place rather than adding a line above the Play chip, and a
|
* takes the synopsis's place without crowding the facts beneath it, and a
|
||||||
* series premiere leading the launcher has to read as deliberate rather than as a TV
|
* series premiere leading the launcher has to read as deliberate rather than as a TV
|
||||||
* show that wandered into the movie hero.
|
* show that wandered into the movie hero.
|
||||||
*/
|
*/
|
||||||
@@ -211,7 +212,7 @@ class HomeMovieHeroScreenshotTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.onNodeWithText("Play").fetchSemanticsNode()
|
assertTrue(compose.onAllNodesWithText("Play").fetchSemanticsNodes().isEmpty())
|
||||||
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
|
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,40 @@ a { color: var(--accent-ink); }
|
|||||||
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
|
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.rail-group {
|
.rail-group {
|
||||||
margin: 16px 12px 6px; color: var(--quiet);
|
width: calc(100% - 18px); margin: 14px 9px 5px; padding: 5px 7px; border: 0;
|
||||||
font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
|
background: transparent; color: var(--quiet); font: inherit; font-size: 10px;
|
||||||
|
font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.rail-group:hover { color: var(--text); }
|
||||||
|
.rail-group-arrow { font-size: 15px; line-height: 1; transition: transform .16s ease; }
|
||||||
|
.rail-group[aria-expanded="false"] .rail-group-arrow { transform: rotate(-90deg); }
|
||||||
.rail-nav { display: grid; gap: 2px; }
|
.rail-nav { display: grid; gap: 2px; }
|
||||||
|
.rail-nav[hidden] { display: none; }
|
||||||
|
|
||||||
|
.journey-summary-grid, .journey-report-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; margin-top:18px; }
|
||||||
|
.journey-summary { border:1px solid var(--line); background:var(--surface-lift); border-radius:14px; padding:16px 18px; }
|
||||||
|
.journey-summary > b, .journey-summary-head b { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.07em; }
|
||||||
|
.journey-summary > strong { display:block; font-size:20px; margin-top:9px; }
|
||||||
|
.journey-summary p { color:var(--muted); margin:8px 0 0; font-size:13px; }
|
||||||
|
.journey-summary-head { display:flex; align-items:center; justify-content:space-between; }
|
||||||
|
.journey-summary-head strong { font-size:22px; }
|
||||||
|
.journey-meter { height:8px; margin-top:13px; overflow:hidden; border-radius:99px; background:var(--line); }
|
||||||
|
.journey-meter span { display:block; height:100%; border-radius:inherit; background:var(--accent); }
|
||||||
|
.table-sub { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
|
||||||
|
.route-arrow { color:var(--accent); padding:0 5px; }
|
||||||
|
.journey-visits { display:grid; gap:14px; }
|
||||||
|
.journey-visit { border:1px solid var(--line); border-radius:14px; background:var(--surface-lift); overflow:hidden; }
|
||||||
|
.journey-visit header { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid var(--line); }
|
||||||
|
.journey-visit header b, .journey-visit header span { display:block; }
|
||||||
|
.journey-visit header span { color:var(--muted); font-size:12px; margin-top:3px; }
|
||||||
|
.journey-visit ol { list-style:none; margin:0; padding:10px 16px 14px; }
|
||||||
|
.journey-visit li { display:grid; grid-template-columns:12px minmax(0,1fr) auto; gap:10px; align-items:start; padding:8px 0; }
|
||||||
|
.journey-step-dot { width:8px; height:8px; margin-top:5px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px var(--accent-wash); }
|
||||||
|
.journey-visit li b, .journey-visit li span { display:block; }
|
||||||
|
.journey-visit li span { color:var(--muted); font-size:12px; margin-top:2px; }
|
||||||
|
.journey-visit time { color:var(--muted); font-size:11px; }
|
||||||
|
@media (max-width:900px) { .journey-summary-grid, .journey-report-grid { grid-template-columns:1fr; } }
|
||||||
.rail-link {
|
.rail-link {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex; align-items: center; gap: 11px; min-height: 36px;
|
display: flex; align-items: center; gap: 11px; min-height: 36px;
|
||||||
@@ -490,6 +520,7 @@ details[open] summary { margin-bottom: 8px; }
|
|||||||
.rail { padding: 16px 7px 10px; }
|
.rail { padding: 16px 7px 10px; }
|
||||||
.rail-brand { justify-content: center; padding: 0 0 14px; }
|
.rail-brand { justify-content: center; padding: 0 0 14px; }
|
||||||
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; }
|
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; }
|
||||||
|
.rail-nav[hidden] { display: grid !important; }
|
||||||
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
|
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
|
||||||
.rail-foot { justify-items: center; padding: 12px 0 0; }
|
.rail-foot { justify-items: center; padding: 12px 0 0; }
|
||||||
.rail-logout { width: auto; padding: 5px; }
|
.rail-logout { width: auto; padding: 5px; }
|
||||||
|
|||||||
@@ -281,7 +281,29 @@ const Admin = (() => {
|
|||||||
if (!document.hidden) refresh();
|
if (!document.hidden) refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
ready(() => { decorate(); refresh(); schedule(); });
|
/* ---- collapsible navigation ----------------------------------------- */
|
||||||
|
|
||||||
|
function prepareNavigation() {
|
||||||
|
document.querySelectorAll('[data-nav-group]').forEach((button) => {
|
||||||
|
const id = button.dataset.navGroup;
|
||||||
|
const panel = document.querySelector('[data-nav-panel="' + id + '"]');
|
||||||
|
if (!panel) return;
|
||||||
|
const active = panel.querySelector('[aria-current="page"]');
|
||||||
|
let expanded = active || localStorage.getItem('memby-admin-nav-' + id) !== 'closed';
|
||||||
|
const apply = () => {
|
||||||
|
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
panel.hidden = !expanded;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
expanded = !expanded;
|
||||||
|
localStorage.setItem('memby-admin-nav-' + id, expanded ? 'open' : 'closed');
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(() => { prepareNavigation(); decorate(); refresh(); schedule(); });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page, api, fmt, ui, $, error, settled, fill, check,
|
page, api, fmt, ui, $, error, settled, fill, check,
|
||||||
|
|||||||
@@ -16,8 +16,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tiles" id="journeys-tiles"></div>
|
<div class="tiles" id="journeys-tiles"></div>
|
||||||
|
<div class="journey-summary-grid">
|
||||||
|
<div class="journey-summary" id="journeys-completion"></div>
|
||||||
|
<div class="journey-summary" id="journeys-insight"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="journey-report-grid">
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><div>
|
||||||
|
<h2 class="card-title" data-icon="chart" data-icon-tone="info">What people do</h2>
|
||||||
|
<p class="card-note">Actions show total use and how many separate visits included them.</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="table-wrap"><table>
|
||||||
|
<thead><tr><th>Action</th><th class="num">Uses</th><th class="num">Visits</th></tr></thead>
|
||||||
|
<tbody id="journeys-actions"></tbody>
|
||||||
|
</table></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><div>
|
||||||
|
<h2 class="card-title" data-icon="list" data-icon-tone="note">Where people go</h2>
|
||||||
|
<p class="card-note">The most common steps between screens, including where quiet visits ended.</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="table-wrap"><table>
|
||||||
|
<thead><tr><th>Route</th><th class="num">Times</th></tr></thead>
|
||||||
|
<tbody id="journeys-paths"></tbody>
|
||||||
|
</table></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head"><div>
|
<div class="card-head"><div>
|
||||||
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
|
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
|
||||||
@@ -29,38 +57,10 @@
|
|||||||
</table></div>
|
</table></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<div class="card-head"><div>
|
|
||||||
<h2 class="card-title" data-icon="chart" data-icon-tone="info">Significant actions</h2>
|
|
||||||
<p class="card-note">Actions are counted by event and by distinct journey, so repeated use
|
|
||||||
inside one visit remains visible without looking like more visits.</p>
|
|
||||||
</div></div>
|
|
||||||
<div class="table-wrap"><table>
|
|
||||||
<thead><tr><th>Category</th><th>Action</th><th class="num">Events</th><th class="num">Journeys</th></tr></thead>
|
|
||||||
<tbody id="journeys-actions"></tbody>
|
|
||||||
</table></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<div class="card-head"><div>
|
|
||||||
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
|
|
||||||
<p class="card-note">Repeated transitions reveal routes into playback and places viewers
|
|
||||||
commonly leave a flow. A quiet unfinished journey becomes abandoned after 30 minutes.</p>
|
|
||||||
</div></div>
|
|
||||||
<div class="table-wrap"><table>
|
|
||||||
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
|
|
||||||
<tbody id="journeys-paths"></tbody>
|
|
||||||
</table></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card" id="journeys-events-card" hidden>
|
<section class="card" id="journeys-events-card" hidden>
|
||||||
<div class="card-head"><div>
|
<div class="card-head"><div>
|
||||||
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journeys</h2>
|
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Individual visits</h2>
|
||||||
<p class="card-note">Choose one user to inspect visits. Newest visits appear first;
|
<p class="card-note">Each card is one visit, read from its first step to its last.</p>
|
||||||
actions within each visit run from start to finish.</p>
|
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="table-wrap"><table>
|
<div class="journey-visits" id="journeys-events"></div>
|
||||||
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
|
|
||||||
<tbody id="journeys-events"></tbody>
|
|
||||||
</table></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -19,22 +19,27 @@ function renderEvents(events) {
|
|||||||
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
|
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
|
||||||
grouped.get(event.journeyId).push(event);
|
grouped.get(event.journeyId).push(event);
|
||||||
});
|
});
|
||||||
let journeyNumber = grouped.size;
|
const visits = [];
|
||||||
const rows = [];
|
|
||||||
grouped.forEach((steps) => {
|
grouped.forEach((steps) => {
|
||||||
steps.sort((a, b) => a.sequence - b.sequence);
|
steps.sort((a, b) => a.sequence - b.sequence);
|
||||||
const number = journeyNumber--;
|
const first = steps[0];
|
||||||
steps.forEach((event) => {
|
const completed = steps.some((event) => event.action === 'journey_end');
|
||||||
|
const actions = steps.filter((event) => !['journey_start', 'journey_end'].includes(event.action));
|
||||||
|
const timeline = actions.map((event) => {
|
||||||
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
|
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
|
||||||
: label(event.target || event.screen);
|
: label(event.target || event.screen);
|
||||||
const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—';
|
const content = event.itemName ? label(event.itemType) + ' · ' + event.itemName : '—';
|
||||||
rows.push('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
|
return '<li><span class="journey-step-dot"></span><div><b>' + fmt.escape(label(event.action)) +
|
||||||
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
|
'</b><span>' + fmt.escape(path) + (content === '—' ? '' : ' · ' + fmt.escape(content)) +
|
||||||
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
|
'</span></div><time>' + fmt.when(event.occurredAt) + '</time></li>';
|
||||||
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
|
}).join('');
|
||||||
|
visits.push('<article class="journey-visit"><header><div><b>' + fmt.when(first.occurredAt) +
|
||||||
|
'</b><span>' + actions.length + ' significant step' + (actions.length === 1 ? '' : 's') +
|
||||||
|
'</span></div>' + ui.tag(completed ? 'completed' : 'unfinished', completed ? 'ok' : 'warn') +
|
||||||
|
'</header><ol>' + (timeline || '<li class="muted">No significant actions recorded.</li>') +
|
||||||
|
'</ol></article>');
|
||||||
});
|
});
|
||||||
});
|
$('journeys-events').innerHTML = visits.length ? visits.join('') : '<p class="empty">No visits in this window.</p>';
|
||||||
$('journeys-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Admin.onRefresh(async () => {
|
Admin.onRefresh(async () => {
|
||||||
@@ -58,9 +63,20 @@ Admin.onRefresh(async () => {
|
|||||||
['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }],
|
['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }],
|
||||||
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
|
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
|
||||||
]);
|
]);
|
||||||
|
const completion = Math.round((stats.completionRate || 0) * 100);
|
||||||
|
$('journeys-completion').innerHTML = '<div class="journey-summary-head"><b>Visit completion</b><strong>' +
|
||||||
|
completion + '%</strong></div><div class="journey-meter"><span style="width:' + completion +
|
||||||
|
'%"></span></div><p>' + fmt.number(stats.completed || 0) + ' completed · ' +
|
||||||
|
fmt.number(stats.abandoned || 0) + ' abandoned · ' + fmt.number(stats.active || 0) + ' active</p>';
|
||||||
|
|
||||||
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
|
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
|
||||||
const features = [...new Set([...featureCatalogue, ...used.keys()])];
|
const cataloguePosition = new Map(featureCatalogue.map((name, index) => [name, index]));
|
||||||
|
const features = [...new Set([...featureCatalogue, ...used.keys()])].sort((left, right) => {
|
||||||
|
const useDifference = (used.get(right)?.uses || 0) - (used.get(left)?.uses || 0);
|
||||||
|
if (useDifference) return useDifference;
|
||||||
|
return (cataloguePosition.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(cataloguePosition.get(right) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
});
|
||||||
$('journeys-features').innerHTML = features.map((name) => {
|
$('journeys-features').innerHTML = features.map((name) => {
|
||||||
const stat = used.get(name);
|
const stat = used.get(name);
|
||||||
const uses = stat?.uses || 0;
|
const uses = stat?.uses || 0;
|
||||||
@@ -71,15 +87,21 @@ Admin.onRefresh(async () => {
|
|||||||
|
|
||||||
const actions = payload.actions || [];
|
const actions = payload.actions || [];
|
||||||
$('journeys-actions').innerHTML = actions.length ? actions.map((action) =>
|
$('journeys-actions').innerHTML = actions.length ? actions.map((action) =>
|
||||||
'<tr><td>' + fmt.escape(label(action.category)) + '</td><td>' + fmt.escape(label(action.action)) +
|
'<tr><td><b>' + fmt.escape(label(action.action)) + '</b><span class="table-sub">' +
|
||||||
'</td><td class="num">' + fmt.number(action.events) + '</td><td class="num">' +
|
fmt.escape(label(action.category)) + '</span></td><td class="num">' + fmt.number(action.events) +
|
||||||
fmt.number(action.journeys) + '</td></tr>').join('') : ui.emptyRow(4, 'No significant actions in this window.');
|
'</td><td class="num">' + fmt.number(action.journeys) + '</td></tr>').join('')
|
||||||
|
: ui.emptyRow(3, 'No significant actions in this window.');
|
||||||
|
|
||||||
const paths = payload.paths || [];
|
const paths = payload.paths || [];
|
||||||
$('journeys-paths').innerHTML = paths.length ? paths.map((path) =>
|
$('journeys-paths').innerHTML = paths.length ? paths.map((path) =>
|
||||||
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
|
'<tr><td>' + fmt.escape(label(path.from)) + ' <span class="route-arrow">→</span> ' +
|
||||||
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
|
fmt.escape(label(path.to)) + '</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
|
||||||
: ui.emptyRow(3, 'No repeated paths in this window.');
|
: ui.emptyRow(2, 'No repeated paths in this window.');
|
||||||
|
const topPath = paths[0];
|
||||||
|
$('journeys-insight').innerHTML = '<b>Most common route</b><strong>' +
|
||||||
|
(topPath ? fmt.escape(label(topPath.from)) + ' → ' + fmt.escape(label(topPath.to)) : 'Not enough data') +
|
||||||
|
'</strong><p>' + (topPath ? fmt.number(topPath.count) + ' times in this window' :
|
||||||
|
'Journeys will appear here as viewers move through Memby.') + '</p>';
|
||||||
|
|
||||||
$('journeys-events-card').hidden = !selected;
|
$('journeys-events-card').hidden = !selected;
|
||||||
if (selected) renderEvents(payload.events || []);
|
if (selected) renderEvents(payload.events || []);
|
||||||
|
|||||||
@@ -15,8 +15,9 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="rail-scroll">
|
<div class="rail-scroll">
|
||||||
{{range .Nav}}
|
{{range .Nav}}
|
||||||
{{if .Label}}<p class="rail-group">{{.Label}}</p>{{end}}
|
{{if .Label}}<button class="rail-group" type="button" data-nav-group="{{.ID}}"
|
||||||
<nav class="rail-nav">
|
aria-expanded="true" aria-controls="rail-nav-{{.ID}}"><span>{{.Label}}</span><span class="rail-group-arrow" aria-hidden="true">⌄</span></button>{{end}}
|
||||||
|
<nav class="rail-nav" id="rail-nav-{{.ID}}" data-nav-panel="{{.ID}}">
|
||||||
{{range .Items}}{{if not .Hidden}}
|
{{range .Items}}{{if not .Hidden}}
|
||||||
<a class="rail-link{{if eq .ID $.Page.ID}} active{{end}}" href="/admin/{{.ID}}"
|
<a class="rail-link{{if eq .ID $.Page.ID}} active{{end}}" href="/admin/{{.ID}}"
|
||||||
{{if eq .ID $.Page.ID}}aria-current="page"{{end}} title="{{.Label}}">
|
{{if eq .ID $.Page.ID}}aria-current="page"{{end}} title="{{.Label}}">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type adminNavItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type adminNavGroup struct {
|
type adminNavGroup struct {
|
||||||
|
ID string
|
||||||
Label string
|
Label string
|
||||||
Items []adminNavItem
|
Items []adminNavItem
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,7 @@ type adminNavGroup struct {
|
|||||||
// Adding a page is an entry here plus admin/pages/<id>.html and admin/pages/<id>.js.
|
// Adding a page is an entry here plus admin/pages/<id>.html and admin/pages/<id>.js.
|
||||||
var adminNav = []adminNavGroup{
|
var adminNav = []adminNavGroup{
|
||||||
{
|
{
|
||||||
|
ID: "overview",
|
||||||
Items: []adminNavItem{
|
Items: []adminNavItem{
|
||||||
{
|
{
|
||||||
ID: "overview", Label: "Overview", Title: "Overview",
|
ID: "overview", Label: "Overview", Title: "Overview",
|
||||||
@@ -52,6 +54,7 @@ var adminNav = []adminNavGroup{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
ID: "people",
|
||||||
Label: "People",
|
Label: "People",
|
||||||
Items: []adminNavItem{
|
Items: []adminNavItem{
|
||||||
{
|
{
|
||||||
@@ -75,6 +78,7 @@ var adminNav = []adminNavGroup{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
ID: "content",
|
||||||
Label: "Content",
|
Label: "Content",
|
||||||
Items: []adminNavItem{
|
Items: []adminNavItem{
|
||||||
{
|
{
|
||||||
@@ -95,6 +99,7 @@ var adminNav = []adminNavGroup{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
ID: "personalisation",
|
||||||
Label: "Personalisation",
|
Label: "Personalisation",
|
||||||
Items: []adminNavItem{
|
Items: []adminNavItem{
|
||||||
{
|
{
|
||||||
@@ -110,6 +115,7 @@ var adminNav = []adminNavGroup{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
ID: "experience",
|
||||||
Label: "Experience",
|
Label: "Experience",
|
||||||
Items: []adminNavItem{
|
Items: []adminNavItem{
|
||||||
{
|
{
|
||||||
@@ -140,6 +146,7 @@ var adminNav = []adminNavGroup{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
ID: "operations",
|
||||||
Label: "Operations",
|
Label: "Operations",
|
||||||
Items: []adminNavItem{
|
Items: []adminNavItem{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
@@ -41,7 +43,7 @@ type journeyEventPayload struct {
|
|||||||
Feature string `json:"feature"`
|
Feature string `json:"feature"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
ItemID string `json:"itemId"`
|
ItemName string `json:"itemName"`
|
||||||
ItemType string `json:"itemType"`
|
ItemType string `json:"itemType"`
|
||||||
Outcome string `json:"outcome"`
|
Outcome string `json:"outcome"`
|
||||||
OccurredAt string `json:"occurredAt"`
|
OccurredAt string `json:"occurredAt"`
|
||||||
@@ -98,17 +100,21 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
|
|||||||
!journeyOutcomes[payload.Outcome] {
|
!journeyOutcomes[payload.Outcome] {
|
||||||
return store.JourneyEvent{}, false
|
return store.JourneyEvent{}, false
|
||||||
}
|
}
|
||||||
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType}
|
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemType}
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
if !safeAnalyticsValue(field, 100) {
|
if !safeAnalyticsValue(field, 100) {
|
||||||
return store.JourneyEvent{}, false
|
return store.JourneyEvent{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if utf8.RuneCountInString(payload.ItemName) > 160 ||
|
||||||
|
strings.IndexFunc(payload.ItemName, unicode.IsControl) >= 0 {
|
||||||
|
return store.JourneyEvent{}, false
|
||||||
|
}
|
||||||
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
|
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
|
||||||
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
|
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
|
||||||
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
|
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
|
||||||
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
||||||
Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType,
|
Target: payload.Target, ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
|
||||||
Outcome: payload.Outcome}, true
|
Outcome: payload.Outcome}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,13 @@ func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) {
|
|||||||
t.Fatal("unknown action was accepted")
|
t.Fatal("unknown action was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJourneyEventRetainsTheItemName(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
|
||||||
|
Action: "open", ItemName: "Hunt for the Wilderpeople", ItemType: "Movie"}
|
||||||
|
event, ok := toJourneyEvent(payload, "u1", now)
|
||||||
|
if !ok || event.ItemName != payload.ItemName {
|
||||||
|
t.Fatalf("item name was not retained: %+v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -403,6 +403,12 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
|
|||||||
if row.ID == "continue" {
|
if row.ID == "continue" {
|
||||||
score = 1_000
|
score = 1_000
|
||||||
}
|
}
|
||||||
|
// This recovery shelf is deliberately surfaced on Home. It may contain only one
|
||||||
|
// title, so its impression sample is naturally small and must not let the generic
|
||||||
|
// engagement sorter bury it below discovery rows.
|
||||||
|
if row.ID == "for-you:pick-up" {
|
||||||
|
score = 900
|
||||||
|
}
|
||||||
ranked = append(ranked, rankedRow{row: row, position: position, score: score})
|
ranked = append(ranked, rankedRow{row: row, position: position, score: score})
|
||||||
}
|
}
|
||||||
sort.SliceStable(ranked, func(i, j int) bool {
|
sort.SliceStable(ranked, func(i, j int) bool {
|
||||||
|
|||||||
@@ -158,6 +158,19 @@ func TestPersonalizeHomeRowsPreservesColdStartDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPersonalizeHomeRowsKeepsAbandonedShowRecoveryNearTheTop(t *testing.T) {
|
||||||
|
rows := []recommend.Row{
|
||||||
|
{ID: "continue"}, {ID: "for-you:pick-up"}, {ID: "highly-engaged"}, {ID: "latest"},
|
||||||
|
}
|
||||||
|
stats := []store.RowStat{{
|
||||||
|
RowID: "highly-engaged", Impressions: 20, Focuses: 20, Selects: 20, DwellMs: 300_000,
|
||||||
|
}}
|
||||||
|
got := personalizeHomeRows(rows, stats)
|
||||||
|
if got[0].ID != "continue" || got[1].ID != "for-you:pick-up" {
|
||||||
|
t.Fatalf("recovery shelf was buried: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreparedHomeRowsPromotesAbandonedShowsAndTimeAwarePicks(t *testing.T) {
|
func TestPreparedHomeRowsPromotesAbandonedShowsAndTimeAwarePicks(t *testing.T) {
|
||||||
prepared := []recommend.Row{
|
prepared := []recommend.Row{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type requestCandidate struct {
|
|||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
PosterURL string `json:"posterUrl,omitempty"`
|
PosterURL string `json:"posterUrl,omitempty"`
|
||||||
AlreadyAdded bool `json:"alreadyAdded"`
|
AlreadyAdded bool `json:"alreadyAdded"`
|
||||||
|
InLibrary bool `json:"inLibrary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type requestLookupResponse struct {
|
type requestLookupResponse struct {
|
||||||
@@ -99,6 +100,27 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
candidates := append(movieCandidates, seriesCandidates...)
|
candidates := append(movieCandidates, seriesCandidates...)
|
||||||
|
movieIDs, seriesIDs := []int{}, []int{}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.MediaType == "movie" {
|
||||||
|
movieIDs = append(movieIDs, candidate.ForeignID)
|
||||||
|
} else {
|
||||||
|
seriesIDs = append(seriesIDs, candidate.ForeignID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moviesInLibrary, movieErr := s.store.LibraryContainsProviderIDs(r.Context(), "Tmdb", movieIDs)
|
||||||
|
seriesInLibrary, seriesErr := s.store.LibraryContainsProviderIDs(r.Context(), "Tvdb", seriesIDs)
|
||||||
|
if movieErr != nil || seriesErr != nil {
|
||||||
|
s.loggerFor(r.Context()).Warn("request library status unavailable",
|
||||||
|
"movie_error", movieErr, "series_error", seriesErr)
|
||||||
|
}
|
||||||
|
for index := range candidates {
|
||||||
|
if candidates[index].MediaType == "movie" {
|
||||||
|
candidates[index].InLibrary = moviesInLibrary[candidates[index].ForeignID]
|
||||||
|
} else {
|
||||||
|
candidates[index].InLibrary = seriesInLibrary[candidates[index].ForeignID]
|
||||||
|
}
|
||||||
|
}
|
||||||
sort.SliceStable(candidates, func(i, j int) bool {
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||||||
return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title)
|
return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,12 +58,16 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
|
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
|
||||||
|
additions := make([]store.SonarrSeriesStatusChange, 0, len(changes))
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if sonarrBecameCancelled(change.PreviousStatus, change.Current.Status) {
|
switch sonarrLifecycleNotificationKind(change.PreviousStatus, change.Current.Status) {
|
||||||
|
case "show-added":
|
||||||
|
additions = append(additions, change)
|
||||||
|
case "show-cancelled":
|
||||||
cancellations = append(cancellations, change)
|
cancellations = append(cancellations, change)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(cancellations) == 0 {
|
if len(cancellations) == 0 && len(additions) == 0 {
|
||||||
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
|
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,36 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
|||||||
preferences := map[string]store.NotificationPreferences{}
|
preferences := map[string]store.NotificationPreferences{}
|
||||||
preferenceErrors := map[string]bool{}
|
preferenceErrors := map[string]bool{}
|
||||||
notifications := 0
|
notifications := 0
|
||||||
|
for _, change := range additions {
|
||||||
|
for _, user := range users {
|
||||||
|
prefs, ok := preferences[user.ID]
|
||||||
|
if !ok && !preferenceErrors[user.ID] {
|
||||||
|
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
preferenceErrors[user.ID] = true
|
||||||
|
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
|
||||||
|
"user", user.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
preferences[user.ID] = prefs
|
||||||
|
}
|
||||||
|
if preferenceErrors[user.ID] || !prefs.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eventAt := change.Current.ObservedAt
|
||||||
|
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||||
|
message := change.Current.Title + " was added to Sonarr."
|
||||||
|
if err := s.store.UpsertNotification(
|
||||||
|
ctx, user.ID, sourceKey, "show-added", "",
|
||||||
|
"Show added", message, &eventAt,
|
||||||
|
); err != nil {
|
||||||
|
s.log.Warn("Sonarr addition notification failed",
|
||||||
|
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
notifications++
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, change := range cancellations {
|
for _, change := range cancellations {
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
prefs, ok := preferences[user.ID]
|
prefs, ok := preferences[user.ID]
|
||||||
@@ -107,10 +141,20 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
s.log.Info("Sonarr lifecycle scan complete",
|
s.log.Info("Sonarr lifecycle scan complete",
|
||||||
"series", len(observations), "changes", len(changes),
|
"series", len(observations), "changes", len(changes),
|
||||||
"cancelled", len(cancellations), "notifications", notifications)
|
"added", len(additions), "cancelled", len(cancellations), "notifications", notifications)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sonarrLifecycleNotificationKind(previous, current string) string {
|
||||||
|
if strings.TrimSpace(previous) == "" {
|
||||||
|
return "show-added"
|
||||||
|
}
|
||||||
|
if sonarrBecameCancelled(previous, current) {
|
||||||
|
return "show-cancelled"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func sonarrSeriesStatusKey(series sonarr.Series) string {
|
func sonarrSeriesStatusKey(series sonarr.Series) string {
|
||||||
if series.TVDBID > 0 {
|
if series.TVDBID > 0 {
|
||||||
return "tvdb:" + strconv.Itoa(series.TVDBID)
|
return "tvdb:" + strconv.Itoa(series.TVDBID)
|
||||||
|
|||||||
@@ -38,3 +38,15 @@ func TestSonarrCancellationRequiresAnActiveHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSonarrLifecycleClassifiesNewSeriesAfterTheBaseline(t *testing.T) {
|
||||||
|
if got := sonarrLifecycleNotificationKind("", "continuing"); got != "show-added" {
|
||||||
|
t.Fatalf("new series notification = %q, want show-added", got)
|
||||||
|
}
|
||||||
|
if got := sonarrLifecycleNotificationKind("continuing", "ended"); got != "show-cancelled" {
|
||||||
|
t.Fatalf("cancelled series notification = %q, want show-cancelled", got)
|
||||||
|
}
|
||||||
|
if got := sonarrLifecycleNotificationKind("continuing", "continuing"); got != "" {
|
||||||
|
t.Fatalf("unchanged series notification = %q, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ type RowEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// JourneyEvent is one significant step through the app. All descriptive fields are
|
// JourneyEvent is one significant step through the app. All descriptive fields are
|
||||||
// controlled vocabulary; ItemID is the only content identity retained.
|
// controlled vocabulary; ItemName is the only free-text content context retained.
|
||||||
type JourneyEvent struct {
|
type JourneyEvent struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
OccurredAt time.Time `json:"occurredAt"`
|
OccurredAt time.Time `json:"occurredAt"`
|
||||||
@@ -72,7 +72,7 @@ type JourneyEvent struct {
|
|||||||
Feature string `json:"feature"`
|
Feature string `json:"feature"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
ItemID string `json:"itemId,omitempty"`
|
ItemName string `json:"itemName,omitempty"`
|
||||||
ItemType string `json:"itemType,omitempty"`
|
ItemType string `json:"itemType,omitempty"`
|
||||||
Outcome string `json:"outcome,omitempty"`
|
Outcome string `json:"outcome,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -211,12 +211,12 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
|
|||||||
batch.Queue(`
|
batch.Queue(`
|
||||||
INSERT INTO journey_events
|
INSERT INTO journey_events
|
||||||
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
|
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
|
||||||
feature, source, target, item_id, item_type, outcome)
|
feature, source, target, item_name, item_type, outcome)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||||
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
|
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
|
||||||
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
|
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
|
||||||
event.Category, event.Action, event.Screen, event.Feature, event.Source,
|
event.Category, event.Action, event.Screen, event.Feature, event.Source,
|
||||||
event.Target, event.ItemID, event.ItemType, event.Outcome)
|
event.Target, event.ItemName, event.ItemType, event.Outcome)
|
||||||
}
|
}
|
||||||
results := s.pool.SendBatch(ctx, batch)
|
results := s.pool.SendBatch(ctx, batch)
|
||||||
defer results.Close()
|
defer results.Close()
|
||||||
@@ -369,7 +369,7 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) (
|
|||||||
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
|
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
|
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
|
||||||
screen, feature, source, target, item_id, item_type, outcome
|
screen, feature, source, target, item_name, item_type, outcome
|
||||||
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
|
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
|
||||||
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
|
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -379,7 +379,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
|
|||||||
out := []JourneyEvent{}
|
out := []JourneyEvent{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v JourneyEvent
|
var v JourneyEvent
|
||||||
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemType, &v.Outcome); err != nil {
|
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemName, &v.ItemType, &v.Outcome); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
|
|||||||
@@ -146,6 +146,40 @@ func (s *Store) SearchLibrary(ctx context.Context, term string, limit int) ([]js
|
|||||||
return collectPayloads(rows)
|
return collectPayloads(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LibraryContainsProviderIDs reports which external catalogue ids are already represented in
|
||||||
|
// Emby. ProviderIds stays inside the imported payload because it is not a ranking field;
|
||||||
|
// request autocomplete is the one place that needs to compare it with Radarr/Sonarr.
|
||||||
|
func (s *Store) LibraryContainsProviderIDs(
|
||||||
|
ctx context.Context, provider string, ids []int,
|
||||||
|
) (map[int]bool, error) {
|
||||||
|
found := map[int]bool{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
values := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id > 0 {
|
||||||
|
values = append(values, fmt.Sprint(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT (payload->'ProviderIds'->>$1)::int
|
||||||
|
FROM library_items
|
||||||
|
WHERE payload->'ProviderIds'->>$1 = ANY($2::text[])`, provider, values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: library provider ids: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found[id] = true
|
||||||
|
}
|
||||||
|
return found, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
|
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
|
||||||
// recommendation engine. User state is applied by the caller, which is the only place
|
// recommendation engine. User state is applied by the caller, which is the only place
|
||||||
// that knows it.
|
// that knows it.
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error
|
|||||||
return shows, rows.Err()
|
return shows, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordSonarrSeriesStatuses appends only first sightings and changes. A first sighting is
|
// RecordSonarrSeriesStatuses appends first sightings and changes. The first complete scan
|
||||||
// the baseline and is deliberately absent from the returned changes, so enabling the
|
// seeds a baseline; a first sighting on later scans is returned with an empty previous
|
||||||
// scanner cannot announce every series that was already cancelled before it existed.
|
// status so the lifecycle scanner can announce a show newly added to Sonarr.
|
||||||
func (s *Store) RecordSonarrSeriesStatuses(
|
func (s *Store) RecordSonarrSeriesStatuses(
|
||||||
ctx context.Context, observations []SonarrSeriesStatus,
|
ctx context.Context, observations []SonarrSeriesStatus,
|
||||||
) ([]SonarrSeriesStatusChange, error) {
|
) ([]SonarrSeriesStatusChange, error) {
|
||||||
@@ -119,6 +119,10 @@ func (s *Store) RecordSonarrSeriesStatuses(
|
|||||||
return nil, fmt.Errorf("store: begin Sonarr status history: %w", err)
|
return nil, fmt.Errorf("store: begin Sonarr status history: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
var seeded bool
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM sonarr_lifecycle_scan_state)`).Scan(&seeded); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: read Sonarr lifecycle seed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := tx.Query(ctx, `
|
rows, err := tx.Query(ctx, `
|
||||||
SELECT DISTINCT ON (series_key) series_key, status
|
SELECT DISTINCT ON (series_key) series_key, status
|
||||||
@@ -172,13 +176,18 @@ func (s *Store) RecordSonarrSeriesStatuses(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: append Sonarr status history: %w", err)
|
return nil, fmt.Errorf("store: append Sonarr status history: %w", err)
|
||||||
}
|
}
|
||||||
if known {
|
if known || seeded {
|
||||||
changes = append(changes, SonarrSeriesStatusChange{
|
changes = append(changes, SonarrSeriesStatusChange{
|
||||||
HistoryID: historyID, PreviousStatus: prior, Current: observation,
|
HistoryID: historyID, PreviousStatus: prior, Current: observation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
previous[observation.SeriesKey] = observation.Status
|
previous[observation.SeriesKey] = observation.Status
|
||||||
}
|
}
|
||||||
|
if !seeded {
|
||||||
|
if _, err := tx.Exec(ctx, `INSERT INTO sonarr_lifecycle_scan_state (singleton) VALUES (true) ON CONFLICT DO NOTHING`); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: seed Sonarr lifecycle scan: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, fmt.Errorf("store: commit Sonarr status history: %w", err)
|
return nil, fmt.Errorf("store: commit Sonarr status history: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ CREATE TABLE IF NOT EXISTS row_events (
|
|||||||
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
|
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
|
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
|
||||||
|
|
||||||
-- Significant, user-scoped app journeys. Values are deliberately categorical: content
|
-- Significant, user-scoped app journeys. Item names make content events recognisable;
|
||||||
-- names, search terms, setting values and other free text do not belong in this table.
|
-- search terms, setting values and other arbitrary free text do not belong in this table.
|
||||||
-- journey_id is generated by the client for one foreground visit; emby_user_id is always
|
-- journey_id is generated by the client for one foreground visit; emby_user_id is always
|
||||||
-- taken from the authenticated gateway session rather than trusted from the payload.
|
-- taken from the authenticated gateway session rather than trusted from the payload.
|
||||||
CREATE TABLE IF NOT EXISTS journey_events (
|
CREATE TABLE IF NOT EXISTS journey_events (
|
||||||
@@ -172,10 +172,13 @@ CREATE TABLE IF NOT EXISTS journey_events (
|
|||||||
source TEXT NOT NULL DEFAULT '',
|
source TEXT NOT NULL DEFAULT '',
|
||||||
target TEXT NOT NULL DEFAULT '',
|
target TEXT NOT NULL DEFAULT '',
|
||||||
item_id TEXT NOT NULL DEFAULT '',
|
item_id TEXT NOT NULL DEFAULT '',
|
||||||
|
item_name TEXT NOT NULL DEFAULT '',
|
||||||
item_type TEXT NOT NULL DEFAULT '',
|
item_type TEXT NOT NULL DEFAULT '',
|
||||||
outcome TEXT NOT NULL DEFAULT ''
|
outcome TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
|
||||||
ON journey_events (emby_user_id, journey_id, sequence);
|
ON journey_events (emby_user_id, journey_id, sequence);
|
||||||
CREATE INDEX IF NOT EXISTS journey_events_user_time_idx
|
CREATE INDEX IF NOT EXISTS journey_events_user_time_idx
|
||||||
@@ -256,6 +259,13 @@ CREATE TABLE IF NOT EXISTS sonarr_series_status_history (
|
|||||||
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
|
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
|
||||||
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
|
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
|
||||||
|
|
||||||
|
-- Separates the scanner's first baseline from a genuinely new series discovered later.
|
||||||
|
-- A dedicated marker also handles an initially empty Sonarr library correctly.
|
||||||
|
CREATE TABLE IF NOT EXISTS sonarr_lifecycle_scan_state (
|
||||||
|
singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||||
|
seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
|
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
|
||||||
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
|
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
|
||||||
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
|
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
|
||||||
|
|||||||
Reference in New Issue
Block a user