0.2.58 - Requests module

This commit is contained in:
ponzischeme89
2026-08-12 14:13:19 +12:00
parent 64f19aeef2
commit 613f203cf9
27 changed files with 3026 additions and 23 deletions
+5 -8
View File
@@ -1,10 +1,10 @@
## 0.2.58 — 2026-08-12
- Added: Improved Requests option to the user selector menu.
## 0.2.57 — 2026-08-12
- Fixed: Televisions now identify themselves to Emby as MbyATV everywhere, including in the playback devices list, instead of reporting the app's own name.
- Fixed: Signing in from the admin console or the web installer is now recorded by Emby as MbyGateway, so the server is no longer listed as though it were a television.
- Fixed: Traliers show the logo from the show in the on screen player vs text.
- Added: Smart search for next episode recap/preview episode from YouTube that plays 2 mins before show end
- Added: Optional setting for next episode recap/preview.
- Fixed: Server logs for playing traliers.
- Fixed: The source IP address for each tralier play should come from the Client's real IP address, not from the server.
## 0.2.56 — 2026-08-12
@@ -13,18 +13,15 @@
- Fixed: Trailer playback no longer needs a second Play press, opens a broken web player or leaves the viewer on a generic playback error.
- Fixed: Back now returns cleanly to the detail page after a trailer, and Memby returns there automatically when no playable source remains.
- Improved: Successful trailer sources are remembered for faster repeat playback without unnecessary provider requests.
- Added: Selected labels, feature switches and safe presentation settings can now be managed by the Memby server, while complete bundled defaults keep the app working normally when the server is slow or unavailable.
- Added: Selected labels, feature switches and safe presentation settings can now be managed by the server, while complete bundled defaults keep the app working normally when the server is slow or unavailable.
- Improved: Search now has a dedicated Request action for finding the exact film or series to add, with clearer in-library and already-requested states.
- Fixed: Film and series requests now use the intended monitored request workflow and provide clearer results when a title cannot be requested.
## 0.2.53 — 2026-08-11
- Fixed: Removed the home-screen message asking viewers to open the For You page.
- Improved: The play button used by the mini hero cards now appears over every focused poster.
- Improved: Reduced the primary hero logo so its supporting details remain visible.
- Improved: Reduced the primary homepage hero logo so its supporting details remain visible.
- Improved: The profile rail now shows the name of the user signed in on the television.
- Improved: Renamed My Alerts to Notifications.
- Improved: Added notifications for every user when Sonarr reports that any show has been cancelled.
- Improved: Added a daily Sonarr lifecycle scan and status history so newly cancelled shows can be identified.
## 0.2.52 — 2026-08-11
- Fixed: The last refreshed date in the Devices list used the incorrect date format.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.57"
val defaultVersionName = "0.2.58"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -220,6 +220,20 @@ class EmbyRepository(private val settings: SettingsStore) {
private val snapshot: Settings
get() = settings.current ?: observedSettings
/**
* Whether the signed-in viewer may ask the household for titles, as last reported by
* [MaintenanceMonitor]'s status poll.
*
* Held here rather than read from the monitor so this class keeps talking only to Emby
* and its own settings — the same reason [observedSettings] is a volatile snapshot
* rather than a suspending read. It is **false until a poll says otherwise**, which is
* what makes the direct path, a signed-out set and an older gateway all refuse by
* default.
*/
@Volatile
var requestsPermitted: Boolean = false
internal set
init {
scope.launch { settings.settingsFlow.collect { observedSettings = it } }
}
@@ -863,8 +877,17 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
/**
* Titles the household could be asked for, matching [term].
*
* A viewer without permission gets an empty list rather than an exception. This is still
* enforcement — nothing is fetched and nothing is shown — but silence is the right shape
* here, because this decorates the ordinary Search tab: throwing would put "media
* requests are not enabled for this user" under the results of everybody who simply
* searched for something the library does not have.
*/
suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
if (!ServerConfig.isGateway) return emptyList()
if (!ServerConfig.isGateway || !requestsPermitted) return emptyList()
return requireGateway().requestLookup(term.trim()).candidates
}
@@ -872,6 +895,7 @@ class EmbyRepository(private val settings: SettingsStore) {
candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
): String {
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
requireRequestPermission()
return requireGateway().requestMedia(
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
mediaType = candidate.mediaType,
@@ -881,6 +905,41 @@ class EmbyRepository(private val settings: SettingsStore) {
).title
}
/** This viewer's own asks, newest first, with the state each has reached. */
suspend fun getMyRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList {
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
requireRequestPermission()
return requireGateway().myRequests()
}
/**
* Stops listing a title among this viewer's requests. It deliberately does not cancel
* anything downstream — the household may be part-way through fetching it, and somebody
* else may have asked for the same thing.
*/
suspend fun removeMediaRequest(mediaType: String, foreignId: Int) {
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
requireRequestPermission()
requireGateway().deleteRequest(mediaType, foreignId)
}
/**
* The data layer's own permission check, deliberately *not* only the hidden menu entry.
*
* Hiding the switcher item is a courtesy — it keeps a viewer from opening a page that can
* only apologise. It is not enforcement, because the flag it reads is a poll result: a
* television holds a stale `true` for up to ten seconds after an operator withdraws
* access, and every screen that can reach these methods is composed from state that
* outlives a single poll. So every request path asserts here as well.
*
* The gateway remains the authority — it refuses with 403 regardless of what this thinks
* — and this check is what turns that refusal into something the app never has to make,
* rather than a round trip that spends a viewer's time to be told no.
*/
private fun requireRequestPermission() {
check(requestsPermitted) { "Media requests are not enabled for this user" }
}
/**
* Recommendation rows on their own, forcing the gateway to build them synchronously
* if its cache is cold. [getHome] already carries them once warm, so this is only
@@ -93,6 +93,7 @@ class MaintenanceMonitor(
private val _installPermissionPrompt = MutableStateFlow(false)
private val _genreBrowserEnabled = MutableStateFlow(false)
private val _tvCalendarEnabled = MutableStateFlow(false)
private val _requestsAllowed = MutableStateFlow(false)
private val _gatewayVersion = MutableStateFlow("")
/**
@@ -131,9 +132,34 @@ class MaintenanceMonitor(
*/
val tvCalendarEnabled: StateFlow<Boolean> = _tvCalendarEnabled.asStateFlow()
/**
* Whether this viewer may ask the household for titles, as of the last poll.
*
* This is what keeps the Requests entry out of the switcher for everybody else, and it
* rides this poll rather than the sign-in because an operator granting somebody access
* must reach a television that is already switched on — the seasonal-theme precedent.
*
* It is **false whenever the server has not said otherwise**: signed out, mid-outage, on
* a gateway that predates the field, or on the direct path where there is nobody to ask.
* A menu entry conjured by a missing field would lead to a page every handler refuses.
* Hiding it is a courtesy, never the enforcement — see [EmbyRepository.getMyRequests].
*/
val requestsAllowed: StateFlow<Boolean> = _requestsAllowed.asStateFlow()
/** Build reported by the connected gateway, for Settings → About. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
/**
* The one writer for request permission, so the flow the switcher reads and the flag the
* repository enforces on can never disagree. Two fields set separately is exactly how a
* hidden menu item ends up in front of a viewer whose data layer will refuse them, or
* the reverse.
*/
private fun setRequestsAllowed(allowed: Boolean) {
_requestsAllowed.value = allowed
repository.requestsPermitted = allowed
}
/**
* Alerts this television has already shown.
*
@@ -205,6 +231,7 @@ class MaintenanceMonitor(
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
setRequestsAllowed(false)
_gatewayVersion.value = ""
dismissAlert()
return@collectLatest
@@ -238,6 +265,7 @@ class MaintenanceMonitor(
status.features[INSTALL_PERMISSION_FEATURE] == true
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
_tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true
setRequestsAllowed(status.requests.allowed)
_gatewayVersion.value = status.gatewayVersion
// Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable
@@ -269,6 +297,7 @@ class MaintenanceMonitor(
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
setRequestsAllowed(false)
_gatewayVersion.value = ""
dismissAlert()
return@collectLatest
@@ -192,8 +192,20 @@ data class GatewayServiceStatus(
* with, which is the palette everything looked like before themes existed.
*/
val theme: GatewayThemeStatus = GatewayThemeStatus(),
/**
* Whether this viewer may ask the household for titles.
*
* Per person rather than per household, which is why it is not in [features] beside the
* rest: the allowlist is the operator's decision about one account. It defaults to
* **not allowed**, so a gateway that predates the field leaves the Requests entry off
* the switcher rather than offering a menu item its own handlers would refuse.
*/
val requests: GatewayRequestAccess = GatewayRequestAccess(),
)
@Serializable
data class GatewayRequestAccess(val allowed: Boolean = false)
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
@Serializable
data class GatewayThemeStatus(
@@ -479,6 +491,45 @@ data class GatewayRequestCandidate(
val posterUrl: String = "",
val alreadyAdded: Boolean = false,
val inLibrary: Boolean = false,
/**
* What pressing this card would mean, decided by the gateway rather than worked out here
* from the booleans above — so the search pane and the requests page can never disagree
* about the same title, and a state added to the server reaches this build unchanged.
* Empty from a gateway that predates it, which [requestCandidateStatus] reads as the
* old behaviour.
*/
val status: String = "",
val statusLabel: String = "",
/** Whether *this viewer* asked for it, which [alreadyAdded] cannot distinguish. */
val mine: Boolean = false,
val released: Boolean = false,
)
/** One card on the viewer's own requests page. */
@Serializable
data class GatewayMediaRequestItem(
val mediaType: String = "",
val foreignId: Int = 0,
val title: String = "",
val year: Int = 0,
val overview: String = "",
val posterUrl: String = "",
val requestedAt: String = "",
val status: String = "",
val statusLabel: String = "",
val statusDetail: String = "",
/** Set once Emby has imported it, so an arrived request can open its own detail page. */
val embyItemId: String = "",
)
@Serializable
data class GatewayMediaRequestList(
val requests: List<GatewayMediaRequestItem> = emptyList(),
/**
* Rides the response so a television whose permission was withdrawn while the page was
* open is told, rather than reading an empty list as "you have asked for nothing".
*/
val allowed: Boolean = false,
)
@Serializable
@@ -115,6 +115,15 @@ interface GatewayApi {
@POST("v1/requests")
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
@GET("v1/requests")
suspend fun myRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList
@retrofit2.http.DELETE("v1/requests/{mediaType}/{foreignId}")
suspend fun deleteRequest(
@Path("mediaType") mediaType: String,
@Path("foreignId") foreignId: Int,
)
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
@GET("v1/recommendations")
suspend fun recommendations(): GatewayRows
@@ -98,6 +98,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistRemove
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
@@ -432,10 +433,22 @@ fun UserSwitcherOverlay(
modifier: Modifier = Modifier,
alertCount: Int = 0,
onOpenAlerts: () -> Unit = {},
/**
* Whether this viewer may ask the household for titles. False hides the entry entirely
* rather than dimming it: an operator's allowlist is not something a viewer can act on,
* so a greyed row would only ever raise a question nothing on this television can
* answer. The data layer refuses regardless — see [EmbyRepository.requestsPermitted].
*/
showRequests: Boolean = false,
onOpenRequests: () -> Unit = {},
) {
val profileIds = profiles.map(EmbyProfile::id)
val focusRequesters = remember(profileIds) {
List(profiles.size + UserSwitcherActionCount) { FocusRequester() }
val actionCount = userSwitcherActionCount(showRequests)
// Re-keyed on the action count as well as the profiles: a permission arriving on a poll
// while this menu is open changes how many rows there are, and a requester list of the
// old length would leave the new row unfocusable.
val focusRequesters = remember(profileIds, actionCount) {
List(profiles.size + actionCount) { FocusRequester() }
}
val profileListState = remember(profileIds) { LazyListState() }
val focusScope = rememberCoroutineScope()
@@ -483,7 +496,7 @@ fun UserSwitcherOverlay(
currentIndex = focusedIndex,
profileCount = profiles.size,
direction = direction,
actionCount = UserSwitcherActionCount,
actionCount = actionCount,
)
focusedIndex = next
profileFocusJob?.cancel()
@@ -567,22 +580,43 @@ fun UserSwitcherOverlay(
},
onClick = onOpenAlerts,
)
// Requests sits between the two because it is news-shaped like Notifications
// rather than administrative like Manage users, which stays last.
if (showRequests) {
UserSwitcherAction(
label = "Manage users",
icon = Icons.Default.Settings,
label = "Requests",
icon = Icons.Default.PlaylistAdd,
modifier = Modifier
.focusRequester(focusRequesters[profiles.size + 1])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size + 1
},
onClick = onOpenRequests,
)
}
val manageIndex = profiles.size + actionCount - 1
UserSwitcherAction(
label = "Manage users",
icon = Icons.Default.Settings,
modifier = Modifier
.focusRequester(focusRequesters[manageIndex])
.onFocusChanged {
if (it.isFocused) focusedIndex = manageIndex
},
onClick = onManageProfiles,
)
}
}
}
/** Notifications, then Manage users. See [userSwitcherNextIndex]. */
private const val UserSwitcherActionCount = 2
/**
* Notifications, then Requests when this viewer may make them, then Manage users.
*
* Pure and derived in one place because three things read it — the requester list's length,
* the D-pad's lower bound and Manage users' own index — and a count that disagreed with the
* rows actually drawn is how the last item in a menu becomes unreachable.
*/
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 3 else 2
@Composable
private fun UserSwitcherProfileItem(
@@ -458,6 +458,17 @@ private fun FeaturedMovieCard(
}
}
}
// The same cue the minis and the poster shelves draw. Without it the one card
// the launcher opens on was the only focusable artwork in the app that did not
// say a press would play it. It sits in the artwork half, clear of the text
// column, which stops at 58% of the width.
if (focused) {
MembyArtworkPlayCue(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 26.dp),
)
}
}
}
}
@@ -138,6 +138,9 @@ import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
import com.ponzischeme89.memby.ui.calendar.CalendarScreen
import com.ponzischeme89.memby.ui.requests.RequestsScreen
import com.ponzischeme89.memby.ui.requests.RequestsViewModel
import com.ponzischeme89.memby.ui.requests.RequestsViewModelFactory
import com.ponzischeme89.memby.ui.genre.GenreDiscoveryStrip
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity
@@ -1925,6 +1928,9 @@ private fun HomeScreen(
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
// Per viewer rather than per household, so it cannot ride the feature map: it decides
// whether the Requests entry is in the switcher at all.
val requestsAllowed by ServiceLocator.maintenance.requestsAllowed.collectAsStateWithLifecycle()
// Only whether there is one, not its countdown — that is collected inside the banner
// so a ticking second never reaches the launcher. This is read here purely to decide
// which of the two top bars gets the strip.
@@ -1975,6 +1981,7 @@ private fun HomeScreen(
),
) + notificationState.notifications
var showNotifications by remember { mutableStateOf(false) }
var showRequests by remember { mutableStateOf(false) }
// Two different things: [launchingItem] is the gate that stops a second Play press
// stacking a second player, and stays shut until one comes back. [resolvingItem] is
// the loading screen, and belongs only to a launch that is waiting on the server.
@@ -2018,6 +2025,12 @@ private fun HomeScreen(
val heroRowEntryFocusRequester = remember { FocusRequester() }
// Feature flags can remove the node that currently owns focus. Move the route first,
// wait for its replacement to compose, then establish an explicit remote target.
// A viewer standing on the Requests page when an operator withdraws access is moved
// off it, the same rule the calendar destination follows: a page nothing behind it will
// answer must not be somewhere a remote is left sitting.
LaunchedEffect(requestsAllowed) {
if (!requestsAllowed) showRequests = false
}
LaunchedEffect(tvCalendarEnabled) {
if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) {
selectedDestination = BrowseDestination.HOME
@@ -2116,6 +2129,7 @@ private fun HomeScreen(
showProfiles = false
userSwitcherVisible = false
showNotifications = false
showRequests = false
detailsItem = null
detailsAiringNotice = null
quickMenuItem = null
@@ -2330,6 +2344,7 @@ private fun HomeScreen(
}
val journeyScreen = when {
showSettings -> "settings"
showRequests -> "requests"
showNotifications -> "notifications"
userSwitcherVisible || showProfiles -> "profiles"
detailsItem != null -> "details"
@@ -3131,6 +3146,16 @@ private fun HomeScreen(
notificationsLoading = false
}
},
showRequests = requestsAllowed,
onOpenRequests = {
homeViewModel.trackJourney(
category = "requests", action = "open", screen = journeyScreen,
feature = "requests", target = "requests",
)
userSwitcherVisible = false
navigationExpanded = false
showRequests = true
},
onDismiss = {
userSwitcherVisible = false
scope.launch {
@@ -3394,6 +3419,54 @@ private fun HomeScreen(
onClose = { closeMyShow(false) },
)
}
if (showRequests) {
// Reached from the user picker, so leaving it returns to the rail rather than to
// whatever card held focus on the launcher behind it — the alerts page's rule.
val closeRequests: () -> Unit = {
showRequests = false
scope.launch {
kotlinx.coroutines.delay(16L)
runCatching { navigationFocusRequester.requestFocus() }
}
}
// Keyed on the profile like every other page here, so switching viewer cannot
// leave one person's requests on screen under somebody else's name.
val requestsViewModel: RequestsViewModel = viewModel(
viewModelStoreOwner = profileViewModelOwner,
factory = RequestsViewModelFactory(repo),
)
val requestsState by requestsViewModel.state.collectAsStateWithLifecycle()
RequestsScreen(
state = requestsState,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
onSelectTab = requestsViewModel::selectTab,
onQueryChanged = requestsViewModel::onQueryChanged,
onAppendToQuery = requestsViewModel::appendToQuery,
onBackspace = requestsViewModel::backspace,
onClearQuery = requestsViewModel::clearQuery,
onRequest = requestsViewModel::request,
onRemove = requestsViewModel::remove,
onOpenItem = { itemId ->
// A request that has arrived opens the thing it became. The stub is
// filled in by focusItem the way a schedule card's is, so the page
// appears at once instead of waiting on an item request.
scope.launch {
runCatching { repo.getItemDetails(itemId) }
.onSuccess { item ->
closeRequests()
detailsAiringNotice = null
detailsTrail = emptyList()
detailsItem = item
}
}
},
onRetry = requestsViewModel::refresh,
onExit = closeRequests,
posterUrlFor = { it.takeIf(String::isNotBlank) },
modifier = Modifier.zIndex(6f),
)
}
if (showNotifications) {
// Reached from the user picker, so leaving it goes back to the rail rather than
// to whatever card happened to hold focus on the launcher behind it.
@@ -0,0 +1,346 @@
package com.ponzischeme89.memby.ui.requests
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
/**
* The card both panes are built from.
*
* Deliberately the TV calendar's programme card rather than a new shape: the same 118dp
* row, the same 214dp artwork block bleeding into the panel under a horizontal gradient,
* the same white 2dp focus border over a 1dp resting one, the same badge in the artwork's
* top-left corner. A request and an episode airing on Thursday are the same kind of object
* something the household is going to have so they read as one system.
*
* Everything is a parameter and nothing is fetched here, which is what lets
* `RequestsScreenshotTest` render the real cards with no server, and what keeps the status
* vocabulary in [RequestPresentation] rather than smeared through the layout.
*/
@Composable
internal fun RequestCard(
title: String,
subtitle: String,
detail: String,
status: String,
statusLabel: String,
mediaType: String,
artworkUrl: String?,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
/** Drawn in place of the trailing affordance while a request is in flight. */
busy: Boolean = false,
/** The trailing affordance, when pressing the card would do something. */
action: RequestCardAction? = null,
) {
val shape = RoundedCornerShape(MembyCardCorner)
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = listOf(title, statusLabel, detail)
.filter(String::isNotBlank)
.joinToString(", "),
modifier = modifier.fillMaxWidth(),
) { focused ->
Row(
Modifier
.fillMaxWidth()
.height(118.dp)
.clip(shape)
.background(MembySurfaceRaised.copy(alpha = 0.72f))
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
shape,
),
) {
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface)) {
// The monogram sits behind the artwork rather than instead of it, so nothing
// has to decide in advance whether a poster will arrive — the cast panel's
// rule. Radarr and Sonarr posters are remote URLs and often slow.
Box(
Modifier.fillMaxSize().background(
Brush.linearGradient(
listOf(MembyAccent.copy(alpha = 0.30f), Color(0xFF102523), MembySurfaceRaised),
),
),
contentAlignment = Alignment.Center,
) {
Text(
title.trim().firstOrNull()?.uppercase().orEmpty(),
color = Color.White.copy(alpha = 0.16f),
fontSize = 52.sp,
fontWeight = FontWeight.Black,
)
}
if (!artworkUrl.isNullOrBlank()) {
AsyncImage(
model = artworkUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
listOf(Color.Transparent, MembySurfaceRaised.copy(alpha = 0.12f), MembySurfaceRaised),
),
),
)
// A requestable title wears no badge. The trailing affordance already says
// "Request" in the accent, and a second grey chip saying the same word is
// the one thing on the card that is not a *state* — badges here mean what
// has become of something, and this one has had nothing become of it yet.
if (status != RequestStatus.REQUESTABLE) {
RequestStatusBadge(
status = status,
label = statusLabel,
modifier = Modifier.align(Alignment.TopStart).padding(9.dp),
)
}
MediaTypeGlyph(
mediaType = mediaType,
modifier = Modifier.align(Alignment.BottomStart).padding(9.dp),
)
}
Column(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 10.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle.isNotBlank()) {
Spacer(Modifier.height(5.dp))
Text(
subtitle,
color = Color.White.copy(alpha = 0.82f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(4.dp))
Text(
detail,
color = requestToneColour(status),
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (busy) {
RequestTrailing(icon = Icons.Default.Schedule, label = "Asking", focused = focused)
} else if (action != null) {
RequestTrailing(icon = action.icon, label = action.label, focused = focused)
}
}
}
}
/** The trailing affordance on a card: what the centre button would do. */
internal data class RequestCardAction(val icon: ImageVector, val label: String)
internal val RequestActionRequest = RequestCardAction(Icons.Default.Add, "Request")
internal val RequestActionPlay = RequestCardAction(Icons.Default.PlayArrow, "Watch")
internal val RequestActionRemove = RequestCardAction(Icons.Default.CheckCircle, "Remove")
@Composable
private fun RequestTrailing(icon: ImageVector, label: String, focused: Boolean) {
Column(
Modifier.fillMaxHeight().width(86.dp).padding(end = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
Modifier
.size(38.dp)
.background(
if (focused) Color.White else MembyControlSurfaceRaised,
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = if (focused) MembySurface else Color.White,
modifier = Modifier.size(19.dp),
)
}
Spacer(Modifier.height(6.dp))
Text(
label.uppercase(),
color = if (focused) Color.White else MembyQuietText,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
/**
* The state, worn in the artwork's corner where the calendar wears its finale badge.
*
* The tone is passed through [requestStatusTone] rather than derived from the label, so a
* state this build has never seen gets the neutral plate and its server-sent wording instead
* of borrowing a colour that would claim something.
*/
@Composable
internal fun RequestStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
val (background, foreground) = when (requestStatusTone(status)) {
// The same four colours the launcher's LifecycleBadge uses, so the words are read
// the same way wherever they appear.
RequestTone.POSITIVE -> MembyAccent to MembyAccentInk
RequestTone.ACTIVE -> Color(0xFF5DA9FF) to MembySurface
RequestTone.WAITING -> Color(0xFFFFB454) to MembySurface
RequestTone.NEUTRAL -> MembyControlSurfaceRaised to MembyOnSurface
}
Text(
text = label.uppercase(),
color = foreground,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
.clip(RoundedCornerShape(MembyChipCorner))
.background(background)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
/** The accent the detail line is drawn in, matching the badge's plate. */
@Composable
internal fun requestToneColour(status: String): Color = when (requestStatusTone(status)) {
RequestTone.POSITIVE -> MembyAccent
RequestTone.ACTIVE -> Color(0xFF5DA9FF)
RequestTone.WAITING -> Color(0xFFFFB454)
RequestTone.NEUTRAL -> MembyMutedText
}
/**
* Film or series, said with a glyph rather than a word.
*
* Both panes mix the two, and on a request page the distinction genuinely matters a
* series takes weeks to arrive and a film does not but it is not worth a second text
* badge competing with the state.
*/
@Composable
private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) {
val icon = if (mediaType == "series") Icons.Default.Tv else Icons.Default.Movie
Box(
modifier
.size(24.dp)
.background(MembySurface.copy(alpha = 0.72f), RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = if (mediaType == "series") "Series" else "Film",
tint = Color.White.copy(alpha = 0.82f),
modifier = Modifier.size(13.dp),
)
}
}
/**
* The card's shape with nothing in it, shown while the first list is loading.
*
* A skeleton rather than a spinner because the page has a strong, regular shape and the
* honest thing to say while it loads is what is about to be there. It is not focusable:
* a viewer must never land on a placeholder and press it.
*/
@Composable
internal fun RequestCardSkeleton(modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier
.fillMaxWidth()
.height(118.dp)
.clip(shape)
.background(MembySurfaceRaised.copy(alpha = 0.34f))
.border(1.dp, Color.White.copy(alpha = 0.05f), shape),
) {
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface.copy(alpha = 0.55f)))
Column(
Modifier.weight(1f).fillMaxHeight().padding(horizontal = 14.dp),
verticalArrangement = Arrangement.Center,
) {
SkeletonBar(widthFraction = 0.52f, height = 15.dp)
Spacer(Modifier.height(9.dp))
SkeletonBar(widthFraction = 0.32f, height = 11.dp)
Spacer(Modifier.height(7.dp))
SkeletonBar(widthFraction = 0.22f, height = 9.dp)
}
}
}
@Composable
private fun SkeletonBar(widthFraction: Float, height: androidx.compose.ui.unit.Dp) {
Box(
Modifier
.fillMaxWidth(widthFraction)
.height(height)
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(4.dp)),
)
}
@@ -0,0 +1,125 @@
package com.ponzischeme89.memby.ui.requests
/**
* How a request state is drawn, kept apart from the composables so it can be unit-tested
* and so the two panes a viewer's own requests and a search result can never colour the
* same word differently.
*
* The slugs are the gateway's ([RequestStatus]), and an unknown one is deliberately legal:
* a state invented on the server must reach an older television as a neutral chip wearing
* whatever label it was sent, not as a blank card or a crash. That is the whole reason the
* server sends `statusLabel` beside `status`.
*/
object RequestStatus {
const val AVAILABLE = "available"
const val PROCESSING = "processing"
const val PENDING = "pending"
const val REQUESTED = "requested"
const val UNAVAILABLE = "unavailable"
const val REQUESTABLE = "requestable"
}
/**
* The colour a state is drawn in.
*
* Four tones, matching the meanings the launcher's [LifecycleBadge] already establishes, so
* a viewer reads them the same way across the app: green is done, blue is in hand, amber is
* waiting on something nobody controls, grey is nothing to say.
*/
enum class RequestTone { POSITIVE, ACTIVE, WAITING, NEUTRAL }
fun requestStatusTone(status: String): RequestTone = when (status) {
RequestStatus.AVAILABLE -> RequestTone.POSITIVE
RequestStatus.PROCESSING -> RequestTone.ACTIVE
RequestStatus.PENDING -> RequestTone.WAITING
RequestStatus.REQUESTED -> RequestTone.ACTIVE
// Unavailable and anything this build has never heard of share the quiet treatment.
// A state with no meaning here must not borrow a colour that claims one.
else -> RequestTone.NEUTRAL
}
/**
* The label to draw. The server's wording wins whenever it sent any, which is what lets a
* new state read correctly on a television that predates it; the local table is only the
* fallback for a gateway too old to send one.
*/
fun requestStatusLabel(status: String, serverLabel: String): String {
val supplied = serverLabel.trim()
if (supplied.isNotEmpty()) return supplied
return when (status) {
RequestStatus.AVAILABLE -> "Available"
RequestStatus.PROCESSING -> "Processing"
RequestStatus.PENDING -> "Pending"
RequestStatus.UNAVAILABLE -> "Unavailable"
RequestStatus.REQUESTABLE -> "Request"
else -> "Requested"
}
}
/**
* Whether pressing a search result would do anything.
*
* Only a title nothing has and nobody has asked for is actionable. A card the viewer already
* requested is deliberately *not* pressing it again would be a second identical ask, and
* the card already says so. This is the client half of a rule the gateway enforces anyway;
* it exists so the button is never offered rather than offered and refused.
*/
fun requestCandidateActionable(status: String): Boolean = when (status) {
RequestStatus.REQUESTABLE -> true
// A gateway that predates the status field sends nothing. Falling back to actionable
// preserves the behaviour those builds already had, where every candidate could be
// pressed and the server decided.
"" -> true
else -> false
}
/**
* What a card is grouped under on the viewer's own page.
*
* Three bands rather than six, because the question somebody opens this page with is "has
* anything I asked for arrived", and a page sorted into one heading per state answers it
* with scrolling. Arrived first, in hand second, and everything with nothing left to happen
* to it last.
*/
enum class RequestGroup(val heading: String) {
READY("Ready to watch"),
IN_PROGRESS("On the way"),
CLOSED("Nothing happening"),
}
fun requestGroupFor(status: String): RequestGroup = when (status) {
RequestStatus.AVAILABLE -> RequestGroup.READY
RequestStatus.PROCESSING, RequestStatus.PENDING, RequestStatus.REQUESTED ->
RequestGroup.IN_PROGRESS
else -> RequestGroup.CLOSED
}
/**
* The page's summary line. It counts what arrived rather than the total, because that is the
* number somebody came to the page for; the total is already visible as the length of the
* list.
*/
fun requestSummaryLabel(total: Int, ready: Int): String = when {
total == 0 -> "Nothing requested yet"
ready == 0 && total == 1 -> "1 request, none ready yet"
ready == 0 -> "$total requests, none ready yet"
ready == total && total == 1 -> "1 request, ready to watch"
ready == total -> "All $total ready to watch"
else -> "$ready of $total ready to watch"
}
/**
* Groups a viewer's requests for display, preserving the server's newest-first order inside
* each band.
*
* The server has already sorted by when somebody asked, and that order is the useful part
* re-sorting by title or by state inside a band would lose it. Empty bands are dropped
* rather than drawn as a heading over nothing.
*/
fun <T> groupRequests(
requests: List<T>,
statusOf: (T) -> String,
): List<Pair<RequestGroup, List<T>>> =
RequestGroup.entries
.map { group -> group to requests.filter { requestGroupFor(statusOf(it)) == group } }
.filter { (_, items) -> items.isNotEmpty() }
@@ -0,0 +1,730 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package com.ponzischeme89.memby.ui.requests
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.search.TvKeyboard
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
/**
* Requests, in the TV calendar's clothes.
*
* The same gradient ground, the same header shape (accent glyph, eyebrow, title, a count on
* the right), the same panel corners and the same cards. Two tabs rather than the calendar's
* week switcher, because the page answers two questions: what did I ask for, and what else
* can I ask for.
*
* **Focus contract**, which is the hard half:
* - The tab strip is the page's entry point and its top row. Left from it is the rail.
* - Down from the tabs enters the pane; Up from anywhere in a pane returns to the tabs.
* - In the discover pane the keyboard's leftmost column goes to the rail and its rightmost
* goes to the results, exactly as the Search tab's does it is literally the same
* keyboard.
* - Back steps out one level per press: pane tabs leave the page.
*
* Nothing here can dead-end: a pane with no cards keeps its notice unfocusable and Up still
* reaches the tabs, and every empty state that *can* act (retry, clear the query) puts a
* focusable control in the pane rather than leaving the remote with nowhere to go.
*/
@Composable
fun RequestsScreen(
state: RequestsUiState,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onSelectTab: (RequestsTab) -> Unit,
onQueryChanged: (String) -> Unit,
onAppendToQuery: (String) -> Unit,
onBackspace: () -> Unit,
onClearQuery: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onRemove: (GatewayMediaRequestItem) -> Unit,
onOpenItem: (String) -> Unit,
onRetry: () -> Unit,
onExit: () -> Unit,
modifier: Modifier = Modifier,
posterUrlFor: (String) -> String? = { it.takeIf(String::isNotBlank) },
) {
val tabsFocusRequester = remember { FocusRequester() }
val paneFocusRequester = remember { FocusRequester() }
val keyboardEntry = remember { FocusRequester() }
var paneFocused by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(32L)
runCatching { contentFocusRequester.requestFocus() }
}
// Back steps out one level per press, the stance every page here takes.
BackHandler(enabled = paneFocused) {
paneFocused = false
runCatching { tabsFocusRequester.requestFocus() }
}
BackHandler(enabled = !paneFocused, onBack = onExit)
BoxWithConstraints(
modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFF070B0D), MembySurface, Color(0xFF07110D)),
),
),
) {
Column(
Modifier.fillMaxSize().padding(start = 38.dp, end = 30.dp, top = 22.dp, bottom = 22.dp),
) {
RequestsHeader(state)
Spacer(Modifier.height(12.dp))
RequestsTabStrip(
selected = state.tab,
tabsFocusRequester = tabsFocusRequester,
contentFocusRequester = contentFocusRequester,
paneFocusRequester = paneFocusRequester,
navigationFocusRequester = navigationFocusRequester,
onSelectTab = {
paneFocused = false
onSelectTab(it)
},
)
Spacer(Modifier.height(14.dp))
when {
// Permission withdrawn while the page was open. Said plainly, because an
// empty list would read as "you have never asked for anything".
!state.allowed -> RequestsNotice(
icon = Icons.Default.Inbox,
heading = "Requests are not available",
body = "This profile is no longer allowed to request titles. Ask whoever looks after Memby.",
)
state.tab == RequestsTab.MINE -> MyRequestsPane(
state = state,
paneFocusRequester = paneFocusRequester,
tabsFocusRequester = tabsFocusRequester,
navigationFocusRequester = navigationFocusRequester,
onFocused = { paneFocused = true },
onRemove = onRemove,
onOpenItem = onOpenItem,
onRetry = onRetry,
onBrowse = { onSelectTab(RequestsTab.DISCOVER) },
posterUrlFor = posterUrlFor,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
else -> DiscoverPane(
state = state,
keyboardEntry = keyboardEntry,
paneFocusRequester = paneFocusRequester,
tabsFocusRequester = tabsFocusRequester,
navigationFocusRequester = navigationFocusRequester,
onFocused = { paneFocused = true },
onAppendToQuery = onAppendToQuery,
onBackspace = onBackspace,
onClearQuery = onClearQuery,
onRequest = onRequest,
posterUrlFor = posterUrlFor,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
}
}
}
}
@Composable
private fun RequestsHeader(state: RequestsUiState) {
val ready = state.requests.count { requestGroupFor(it.status) == RequestGroup.READY }
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlaylistAdd, null,
tint = MembyAccent, modifier = Modifier.size(21.dp),
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("REQUESTS", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold)
Text(
"Ask for something",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
}
// Only over the list it describes. Beside a pane of search results it is a count of
// something else on screen, which reads as a caption for the wrong thing.
if (!state.loadingRequests && state.tab == RequestsTab.MINE) {
Text(
requestSummaryLabel(state.requests.size, ready),
color = MembyMutedText,
fontSize = 13.sp,
)
}
}
}
@Composable
private fun RequestsTabStrip(
selected: RequestsTab,
tabsFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
paneFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester,
onSelectTab: (RequestsTab) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
RequestsTab.entries.forEachIndexed { index, tab ->
val isSelected = tab == selected
FocusScaleContainer(
// Focus is not selection here, unlike the detail page's tab strip: pressing
// a tab reloads a pane and moves focus into it, so following the D-pad would
// fire a request every time somebody passed over it on the way to the rail.
onFocused = {},
onClick = { onSelectTab(tab) },
contentDescription = tab.label,
modifier = Modifier
.then(
// The selected tab is where the page opens and where Back returns
// to, so both anchors hang off it rather than off a fixed index.
if (isSelected) {
Modifier
.focusRequester(tabsFocusRequester)
.focusRequester(contentFocusRequester)
} else {
Modifier
},
)
.focusProperties {
if (index == 0) left = navigationFocusRequester
down = paneFocusRequester
}
.background(
MembySurfaceRaised.copy(alpha = 0.62f),
RoundedCornerShape(MembyChipCorner),
),
) { focused ->
Row(
Modifier
.background(
when {
focused -> Color.White
isSelected -> MembyAccent.copy(alpha = 0.18f)
else -> Color.Transparent
},
RoundedCornerShape(MembyChipCorner),
)
.then(
if (isSelected && !focused) {
Modifier.border(
1.dp,
MembyAccent.copy(alpha = 0.55f),
RoundedCornerShape(MembyChipCorner),
)
} else {
Modifier
},
)
.padding(horizontal = 18.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (tab == RequestsTab.MINE) Icons.Default.Inbox else Icons.Default.Search,
null,
tint = if (focused) MembySurface else MembyAccent,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(8.dp))
Text(
tab.label,
color = if (focused) MembySurface else Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
@Composable
private fun MyRequestsPane(
state: RequestsUiState,
paneFocusRequester: FocusRequester,
tabsFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester,
onFocused: () -> Unit,
onRemove: (GatewayMediaRequestItem) -> Unit,
onOpenItem: (String) -> Unit,
onRetry: () -> Unit,
onBrowse: () -> Unit,
posterUrlFor: (String) -> String?,
modifier: Modifier = Modifier,
) {
if (state.loadingRequests && state.requests.isEmpty()) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
repeat(4) { RequestCardSkeleton() }
}
return
}
if (state.requestsError != null && state.requests.isEmpty()) {
RequestsNotice(
icon = Icons.Default.Inbox,
heading = "Could not load your requests",
body = state.requestsError,
action = "Try again",
onAction = onRetry,
actionFocusRequester = paneFocusRequester,
tabsFocusRequester = tabsFocusRequester,
navigationFocusRequester = navigationFocusRequester,
modifier = modifier,
)
return
}
if (state.requests.isEmpty()) {
RequestsNotice(
icon = Icons.Default.Inbox,
heading = "You have not asked for anything yet",
body = "Find a film or series and it will show up here while the household gets hold of it.",
action = "Request something",
onAction = onBrowse,
actionFocusRequester = paneFocusRequester,
tabsFocusRequester = tabsFocusRequester,
navigationFocusRequester = navigationFocusRequester,
modifier = modifier,
)
return
}
val groups = remember(state.requests) {
groupRequests(state.requests) { it.status }
}
// The first card of the first group is the pane's entry point, so Down from the tabs
// always lands somewhere real regardless of which band happens to be first today.
val firstKey = groups.firstOrNull()?.second?.firstOrNull()
?.let { candidateKey(it.mediaType, it.foreignId) }
LazyColumn(
state = rememberLazyListState(),
modifier = modifier,
contentPadding = PaddingValues(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
groups.forEach { (group, requests) ->
item(key = "heading-${group.name}") {
RequestGroupHeading(group.heading, requests.size)
}
items(requests, key = { candidateKey(it.mediaType, it.foreignId) }) { request ->
val key = candidateKey(request.mediaType, request.foreignId)
val watchable = request.status == RequestStatus.AVAILABLE &&
request.embyItemId.isNotBlank()
RequestCard(
title = request.title,
subtitle = listOfNotNull(
request.year.takeIf { it > 0 }?.toString(),
if (request.mediaType == "series") "Series" else "Film",
).joinToString(" · "),
detail = request.statusDetail.ifBlank {
requestStatusLabel(request.status, request.statusLabel)
},
status = request.status,
statusLabel = requestStatusLabel(request.status, request.statusLabel),
mediaType = request.mediaType,
artworkUrl = posterUrlFor(request.posterUrl),
// A request that has arrived opens the thing it became; anything else
// has nothing to play, so the press stops listing it instead.
action = if (watchable) RequestActionPlay else RequestActionRemove,
onFocused = onFocused,
onClick = {
if (watchable) onOpenItem(request.embyItemId) else onRemove(request)
},
modifier = Modifier
.then(
if (key == firstKey) {
Modifier.focusRequester(paneFocusRequester)
} else {
Modifier
},
)
.focusProperties {
left = navigationFocusRequester
if (key == firstKey) up = tabsFocusRequester
},
)
}
}
}
}
@Composable
private fun RequestGroupHeading(heading: String, count: Int) {
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
heading.uppercase(),
color = MembyAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
)
Spacer(Modifier.width(8.dp))
Text("$count", color = MembyQuietText, fontSize = 11.sp)
}
}
@Composable
private fun DiscoverPane(
state: RequestsUiState,
keyboardEntry: FocusRequester,
paneFocusRequester: FocusRequester,
tabsFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester,
onFocused: () -> Unit,
onAppendToQuery: (String) -> Unit,
onBackspace: () -> Unit,
onClearQuery: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
posterUrlFor: (String) -> String?,
modifier: Modifier = Modifier,
) {
val resultsEntry = remember { FocusRequester() }
var lastKeyIndex by remember { mutableStateOf(0) }
val hasResults = state.candidates.isNotEmpty()
Row(modifier) {
Column(
Modifier
.width(340.dp)
.fillMaxHeight()
.onFocusChanged { if (it.hasFocus) onFocused() },
) {
QueryLine(state)
Spacer(Modifier.height(12.dp))
Box(
Modifier
// The keyboard is the pane's entry point and its top-left control, so
// Down from the tabs lands on it and Up from it goes back.
.focusRequester(paneFocusRequester)
.focusProperties { up = tabsFocusRequester },
) {
TvKeyboard(
navigationFocusRequester = navigationFocusRequester,
resultsEntry = resultsEntry,
keyboardEntry = keyboardEntry,
keyboardReturn = keyboardEntry,
lastKeyIndex = lastKeyIndex,
hasResultsTarget = hasResults,
onKeyFocused = { lastKeyIndex = it },
onCharacter = onAppendToQuery,
onBackspace = onBackspace,
onClear = onClearQuery,
)
}
}
Spacer(Modifier.width(20.dp))
CandidatesPane(
state = state,
resultsEntry = resultsEntry,
keyboardEntry = keyboardEntry,
tabsFocusRequester = tabsFocusRequester,
onFocused = onFocused,
onRequest = onRequest,
posterUrlFor = posterUrlFor,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
}
/** What has been typed, echoed above the keyboard the way the Search tab echoes it. */
@Composable
private fun QueryLine(state: RequestsUiState) {
Column(
Modifier
.fillMaxWidth()
.background(MembySurfaceRaised.copy(alpha = 0.34f), RoundedCornerShape(MembyCardCorner))
.border(1.dp, Color.White.copy(alpha = 0.06f), RoundedCornerShape(MembyCardCorner))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text("SEARCH", color = MembyAccent, fontSize = 9.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(3.dp))
Text(
state.query.ifBlank { "Type a film or series name" },
color = if (state.query.isBlank()) MembyQuietText else Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun CandidatesPane(
state: RequestsUiState,
resultsEntry: FocusRequester,
keyboardEntry: FocusRequester,
tabsFocusRequester: FocusRequester,
onFocused: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
posterUrlFor: (String) -> String?,
modifier: Modifier = Modifier,
) {
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
Column(Modifier.weight(1f)) {
Text("RESULTS", color = MembyAccent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
Text(
when {
state.query.isBlank() -> "What are you after?"
!shouldLookup(state.query) -> "Keep typing…"
state.searching -> "Looking…"
else -> state.query.trim()
},
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
state.notice?.let {
Text(
it,
color = if (state.noticeIsError) Color(0xFFE0605A) else MembyAccent,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
Spacer(Modifier.height(9.dp))
if (state.searching && state.candidates.isEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
repeat(3) { RequestCardSkeleton() }
}
return@Column
}
if (state.candidates.isEmpty()) {
PaneMessage(
heading = when {
state.searchError != null -> "Search failed"
state.query.isBlank() -> "Search for a film or series"
!shouldLookup(state.query) -> "Type a little more"
state.hasSearched -> "Nothing found"
else -> "Search for a film or series"
},
body = state.searchError
?: when {
state.query.isBlank() ->
"Use the keyboard to find something the household does not have yet."
!shouldLookup(state.query) ->
"At least ${RequestsViewModel.MIN_QUERY_LENGTH} characters, so the search has something to go on."
state.hasSearched ->
"Nothing matched “${state.query.trim()}”. Check the spelling, or try the original title."
else -> "Use the keyboard to find something the household does not have yet."
},
)
return@Column
}
LazyColumn(
state = rememberLazyListState(),
contentPadding = PaddingValues(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(
state.candidates,
key = { candidateKey(it.mediaType, it.foreignId) },
) { candidate ->
val key = candidateKey(candidate.mediaType, candidate.foreignId)
val actionable = requestCandidateActionable(candidate.status)
RequestCard(
title = candidate.title,
subtitle = listOfNotNull(
candidate.year.takeIf { it > 0 }?.toString(),
if (candidate.mediaType == "series") "Series" else "Film",
).joinToString(" · "),
detail = candidateDetail(candidate),
status = candidate.status,
statusLabel = requestStatusLabel(candidate.status, candidate.statusLabel),
mediaType = candidate.mediaType,
artworkUrl = posterUrlFor(candidate.posterUrl),
busy = key in state.submitting,
action = if (actionable) RequestActionRequest else null,
onFocused = onFocused,
onClick = { if (actionable) onRequest(candidate) },
modifier = Modifier
.then(
if (candidate == state.candidates.first()) {
Modifier.focusRequester(resultsEntry)
} else {
Modifier
},
)
.focusProperties {
// Left always returns to the keyboard rather than falling
// through to the rail: the keyboard is what this pane is for.
left = keyboardEntry
if (candidate == state.candidates.first()) up = tabsFocusRequester
},
)
}
}
}
}
/** The line under a search result: why it is in this state, in plain words. */
private fun candidateDetail(candidate: GatewayRequestCandidate): String = when (candidate.status) {
RequestStatus.AVAILABLE -> "Already in your library"
RequestStatus.REQUESTED -> "You have already asked for this"
RequestStatus.PROCESSING -> "The household is already getting this"
RequestStatus.PENDING -> "Already tracked, not out yet"
RequestStatus.REQUESTABLE -> "Press to request"
else -> candidate.statusLabel.ifBlank { "Press to request" }
}
@Composable
private fun PaneMessage(heading: String, body: String) {
Box(
Modifier
.fillMaxSize()
.background(MembySurfaceRaised.copy(alpha = 0.22f), RoundedCornerShape(MembyPanelCorner))
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(MembyPanelCorner)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 40.dp),
) {
Icon(Icons.Default.Search, null, tint = MembyQuietText, modifier = Modifier.size(30.dp))
Spacer(Modifier.height(8.dp))
Text(heading, color = MembyMutedText, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
Text(body, color = MembyQuietText, fontSize = 12.sp)
}
}
}
/**
* A whole-pane notice, optionally with something to press.
*
* When it carries an action that control takes the pane's focus anchor, so Down from the
* tabs still lands somewhere and Up from it still returns an empty page must never be a
* page the remote is stuck on.
*/
@Composable
private fun RequestsNotice(
icon: androidx.compose.ui.graphics.vector.ImageVector,
heading: String,
body: String,
modifier: Modifier = Modifier,
action: String? = null,
onAction: () -> Unit = {},
actionFocusRequester: FocusRequester? = null,
tabsFocusRequester: FocusRequester? = null,
navigationFocusRequester: FocusRequester? = null,
) {
Column(
modifier
.fillMaxSize()
.background(MembySurfaceRaised.copy(alpha = 0.22f), RoundedCornerShape(MembyPanelCorner))
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(MembyPanelCorner))
.padding(30.dp),
verticalArrangement = Arrangement.Center,
) {
Icon(icon, null, tint = MembyQuietText, modifier = Modifier.size(32.dp))
Spacer(Modifier.height(10.dp))
Text(heading, color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Text(body, color = MembyMutedText, fontSize = 14.sp)
if (action != null) {
Spacer(Modifier.height(16.dp))
FocusScaleContainer(
onFocused = {},
onClick = onAction,
contentDescription = action,
modifier = Modifier
.then(
actionFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier,
)
.focusProperties {
tabsFocusRequester?.let { up = it }
navigationFocusRequester?.let { left = it }
}
.background(MembySurfaceRaised, RoundedCornerShape(MembyChipCorner)),
) { focused ->
Text(
action,
color = if (focused) MembySurface else Color.White,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.background(
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(MembyChipCorner),
)
.padding(horizontal = 18.dp, vertical = 10.dp),
)
}
}
}
}
@@ -0,0 +1,290 @@
package com.ponzischeme89.memby.ui.requests
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/** Which half of the page is showing. */
enum class RequestsTab(val label: String) {
MINE("My requests"),
DISCOVER("Request something"),
}
data class RequestsUiState(
val tab: RequestsTab = RequestsTab.MINE,
/** The viewer's own asks, newest first, as the gateway grouped and stated them. */
val requests: List<GatewayMediaRequestItem> = emptyList(),
val loadingRequests: Boolean = true,
val requestsError: String? = null,
val query: String = "",
val candidates: List<GatewayRequestCandidate> = emptyList(),
val searching: Boolean = false,
val hasSearched: Boolean = false,
val searchError: String? = null,
/**
* Which candidates have a request in flight, keyed the way the wire keys them. A set
* rather than a single id because a viewer can walk down the pane pressing several
* before the first answers, and each card has to show its own progress.
*/
val submitting: Set<String> = emptySet(),
/** The last thing that happened, shown once as a quiet line rather than a dialog. */
val notice: String? = null,
val noticeIsError: Boolean = false,
/**
* False once the gateway says this viewer may not request. The page can be open when
* that arrives an operator can withdraw access at any moment and an empty list is
* not the same answer as "you are not allowed to ask".
*/
val allowed: Boolean = true,
)
/** The wire's identity for a title: catalogue plus id. Used for in-flight bookkeeping. */
internal fun candidateKey(mediaType: String, foreignId: Int): String = "$mediaType:$foreignId"
/**
* Owns the two panes' state.
*
* The search half reuses the Search tab's pipeline exactly `debounce` `trim`
* `distinctUntilChanged` `collectLatest` and `collectLatest` is the load-bearing part
* for the same reason it is there: it cancels the in-flight lookup, so a slow answer for a
* prefix can never overwrite the results for what was typed after it. Radarr and Sonarr
* lookups are live provider queries measured in seconds, so that race is not hypothetical
* here; it is the ordinary case.
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _state = MutableStateFlow(RequestsUiState())
val state: StateFlow<RequestsUiState> = _state.asStateFlow()
private val queryFlow = MutableStateFlow("")
private var refreshJob: Job? = null
init {
viewModelScope.launch {
queryFlow
.debounce(DEBOUNCE_MS)
.map { it.trim() }
.distinctUntilChanged()
.collectLatest { term -> runLookup(term) }
}
refresh()
}
fun selectTab(tab: RequestsTab) {
if (_state.value.tab == tab) return
_state.update { it.copy(tab = tab, notice = null) }
// Coming back to the list is when somebody wants to see whether the thing they just
// asked for has moved on, so this is the refresh that matters most.
if (tab == RequestsTab.MINE) refresh()
}
fun refresh() {
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
_state.update { it.copy(loadingRequests = true, requestsError = null) }
runCatching { repository.getMyRequests() }
.onSuccess { list ->
_state.update {
it.copy(
requests = list.requests,
allowed = list.allowed,
loadingRequests = false,
requestsError = null,
)
}
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
_state.update {
it.copy(
loadingRequests = false,
requestsError = friendlyEmbyError(error),
)
}
}
}
}
fun onQueryChanged(query: String) {
// The field updates immediately; only the lookup is debounced.
_state.update {
it.copy(
query = query,
notice = null,
// Results for the previous term are cleared as soon as the term changes.
// Leaving them up under a new query is how somebody requests the wrong film.
candidates = if (shouldLookup(query)) it.candidates else emptyList(),
hasSearched = if (shouldLookup(query)) it.hasSearched else false,
searchError = null,
)
}
queryFlow.value = query
}
fun appendToQuery(text: String) = onQueryChanged(_state.value.query + text)
fun backspace() = onQueryChanged(_state.value.query.dropLast(1))
fun clearQuery() {
_state.update {
it.copy(
query = "", candidates = emptyList(), searching = false,
hasSearched = false, searchError = null, notice = null,
)
}
queryFlow.value = ""
}
private suspend fun runLookup(term: String) {
if (!shouldLookup(term)) {
_state.update { it.copy(searching = false, candidates = emptyList(), hasSearched = false) }
return
}
_state.update { it.copy(searching = true, searchError = null) }
runCatching { repository.lookupMediaRequests(term) }
.onSuccess { candidates ->
// Guard against a response for a term the viewer has already typed past.
// collectLatest cancels the coroutine, but a call already past its last
// suspension point still completes — the calendar's requestedMonth rule.
if (_state.value.query.trim() != term) return@onSuccess
_state.update {
it.copy(candidates = candidates, searching = false, hasSearched = true)
}
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
if (_state.value.query.trim() != term) return@onFailure
_state.update {
it.copy(
searching = false, hasSearched = true,
searchError = friendlyEmbyError(error),
)
}
}
}
/**
* Asks for a title.
*
* The card is marked in flight rather than the page, so the rest of the pane stays live
* a viewer who asked for one thing usually wants the next one too, and a page that
* locks while Radarr thinks reads as broken.
*/
fun request(candidate: GatewayRequestCandidate) {
val key = candidateKey(candidate.mediaType, candidate.foreignId)
if (key in _state.value.submitting) return
if (!requestCandidateActionable(candidate.status)) return
_state.update { it.copy(submitting = it.submitting + key, notice = null) }
viewModelScope.launch {
runCatching { repository.requestMedia(candidate) }
.onSuccess { title ->
_state.update { current ->
current.copy(
submitting = current.submitting - key,
notice = "Requested ${title.ifBlank { candidate.title }}",
noticeIsError = false,
// Reflect it on the card immediately rather than waiting for a
// re-lookup: the viewer pressed it, and a card that still says
// "Request" is one they press again.
candidates = current.candidates.map {
if (candidateKey(it.mediaType, it.foreignId) == key) {
it.copy(status = RequestStatus.REQUESTED, mine = true, statusLabel = "")
} else {
it
}
},
)
}
// The list behind the other tab is now out of date.
refresh()
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
_state.update {
it.copy(
submitting = it.submitting - key,
notice = friendlyEmbyError(error),
noticeIsError = true,
)
}
}
}
}
/**
* Stops listing one of the viewer's own requests.
*
* Removed from the list optimistically, the stance the alerts page takes: this is the
* one page whose job is emptying itself, and a row that lingers under a thumb is a row
* that gets pressed again.
*/
fun remove(request: GatewayMediaRequestItem) {
val key = candidateKey(request.mediaType, request.foreignId)
val previous = _state.value.requests
_state.update { current ->
current.copy(
requests = current.requests.filterNot {
candidateKey(it.mediaType, it.foreignId) == key
},
notice = null,
)
}
viewModelScope.launch {
runCatching { repository.removeMediaRequest(request.mediaType, request.foreignId) }
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
// Put it back. A request that silently stayed removed on screen while
// the server still held it would reappear on the next refresh anyway,
// which is more confusing than saying so now.
_state.update {
it.copy(
requests = previous,
notice = friendlyEmbyError(error),
noticeIsError = true,
)
}
}
}
}
fun dismissNotice() = _state.update { it.copy(notice = null) }
companion object {
const val DEBOUNCE_MS = 300L
/**
* Three characters, not the Search tab's two.
*
* Every keystroke here reaches Radarr and Sonarr rather than a local index, and a
* two-letter prefix returns a hundred films nobody meant while costing two live
* provider queries. The gateway independently refuses under two.
*/
const val MIN_QUERY_LENGTH = 3
}
}
fun shouldLookup(query: String): Boolean =
query.trim().length >= RequestsViewModel.MIN_QUERY_LENGTH
class RequestsViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
RequestsViewModel(repository) as T
}
@@ -492,8 +492,15 @@ private fun QueryField(
}
}
/**
* Internal rather than private so the Requests page shares this exact keyboard.
*
* Two on-screen keyboards in one app is two focus contracts to keep in step, and the one
* thing a viewer must never have to relearn is where the letters are and what Left does at
* the edge of them.
*/
@Composable
private fun TvKeyboard(
internal fun TvKeyboard(
navigationFocusRequester: FocusRequester,
resultsEntry: FocusRequester,
keyboardEntry: FocusRequester,
@@ -0,0 +1,138 @@
package com.ponzischeme89.memby.ui.requests
import com.ponzischeme89.memby.ui.userSwitcherActionCount
import com.ponzischeme89.memby.ui.UserSwitcherDirection
import com.ponzischeme89.memby.ui.userSwitcherNextIndex
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RequestPresentationTest {
@Test
fun `an unknown state is drawn quietly rather than borrowing a colour`() {
// A state invented on the gateway must reach an older television as a neutral chip.
// Giving it a tone would have it claim something this build cannot know.
assertEquals(RequestTone.NEUTRAL, requestStatusTone("approved"))
assertEquals(RequestTone.NEUTRAL, requestStatusTone(""))
assertEquals(RequestTone.NEUTRAL, requestStatusTone(RequestStatus.UNAVAILABLE))
}
@Test
fun `each meaningful state keeps its own tone`() {
assertEquals(RequestTone.POSITIVE, requestStatusTone(RequestStatus.AVAILABLE))
assertEquals(RequestTone.ACTIVE, requestStatusTone(RequestStatus.PROCESSING))
assertEquals(RequestTone.ACTIVE, requestStatusTone(RequestStatus.REQUESTED))
assertEquals(RequestTone.WAITING, requestStatusTone(RequestStatus.PENDING))
}
@Test
fun `the server's wording wins so a new state reads correctly on an old build`() {
assertEquals("Awaiting approval", requestStatusLabel("approval_pending", "Awaiting approval"))
// Only when the gateway sent nothing does the local table answer.
assertEquals("Pending", requestStatusLabel(RequestStatus.PENDING, ""))
assertEquals("Pending", requestStatusLabel(RequestStatus.PENDING, " "))
// And a state with no local wording still says something rather than going blank.
assertEquals("Requested", requestStatusLabel("approval_pending", ""))
}
@Test
fun `only a title nobody has and nobody asked for can be pressed`() {
assertTrue(requestCandidateActionable(RequestStatus.REQUESTABLE))
assertFalse(requestCandidateActionable(RequestStatus.AVAILABLE))
assertFalse(requestCandidateActionable(RequestStatus.PROCESSING))
assertFalse(requestCandidateActionable(RequestStatus.PENDING))
// Pressing an already-made request again would be a second identical ask.
assertFalse(requestCandidateActionable(RequestStatus.REQUESTED))
}
@Test
fun `a gateway that predates the status field keeps the old press-anything behaviour`() {
assertTrue(requestCandidateActionable(""))
}
@Test
fun `states collapse into the three bands somebody actually asks about`() {
assertEquals(RequestGroup.READY, requestGroupFor(RequestStatus.AVAILABLE))
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.PROCESSING))
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.PENDING))
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.REQUESTED))
assertEquals(RequestGroup.CLOSED, requestGroupFor(RequestStatus.UNAVAILABLE))
// An unknown state has nothing left to happen to it as far as this build knows.
assertEquals(RequestGroup.CLOSED, requestGroupFor("approved"))
}
@Test
fun `grouping keeps the server's order and drops empty bands`() {
val requests = listOf(
"b" to RequestStatus.PROCESSING,
"a" to RequestStatus.AVAILABLE,
"c" to RequestStatus.PENDING,
"d" to RequestStatus.AVAILABLE,
)
val groups = groupRequests(requests) { it.second }
// Nothing is closed, so that heading is not drawn over an empty band.
assertEquals(listOf(RequestGroup.READY, RequestGroup.IN_PROGRESS), groups.map { it.first })
// Within a band the server's newest-first order survives: "a" was listed before "d",
// and re-sorting alphabetically or by state would lose the only useful ordering.
assertEquals(listOf("a", "d"), groups[0].second.map { it.first })
assertEquals(listOf("b", "c"), groups[1].second.map { it.first })
}
@Test
fun `the summary counts what arrived rather than the total`() {
assertEquals("Nothing requested yet", requestSummaryLabel(0, 0))
assertEquals("1 request, none ready yet", requestSummaryLabel(1, 0))
assertEquals("4 requests, none ready yet", requestSummaryLabel(4, 0))
assertEquals("1 request, ready to watch", requestSummaryLabel(1, 1))
assertEquals("All 3 ready to watch", requestSummaryLabel(3, 3))
assertEquals("2 of 5 ready to watch", requestSummaryLabel(5, 2))
}
@Test
fun `the lookup threshold is stricter than the library search's`() {
// Every keystroke here reaches Radarr and Sonarr rather than a local index.
assertFalse(shouldLookup("du"))
assertTrue(shouldLookup("dun"))
assertFalse(shouldLookup(" a "))
assertTrue(shouldLookup(" dune "))
}
@Test
fun `the switcher's action count matches the rows actually drawn`() {
assertEquals(2, userSwitcherActionCount(showRequests = false))
assertEquals(3, userSwitcherActionCount(showRequests = true))
}
@Test
fun `the D-pad reaches the last switcher row whether or not Requests is shown`() {
// The bug this guards is the one that makes Manage users unreachable: an action
// count that disagrees with the number of rows caps the D-pad one row short.
val profiles = listOf("p1", "p2")
val withRequests = userSwitcherActionCount(showRequests = true)
var index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withRequests,
)
}
assertEquals(profiles.size + withRequests - 1, index)
val withoutRequests = userSwitcherActionCount(showRequests = false)
index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withoutRequests,
)
}
assertEquals(profiles.size + withoutRequests - 1, index)
}
}
@@ -0,0 +1,241 @@
package com.ponzischeme89.memby.ui.requests
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders Requests to PNGs under `build/screenshots/requests/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*RequestsScreenshotTest"
* ```
*
* The states worth looking at are the ones a unit test cannot describe: whether the status
* badges are separable at television distance, whether a card carrying no artwork still
* reads (Radarr and Sonarr posters are remote and frequently slow or absent), and whether
* the skeletons look like the page that is about to arrive rather than like a fault.
*
* There is no network here every URL is blank on purpose, so what these capture is the
* artwork-free fallback, which is the case the calendar's own screenshot test exists for
* too.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RequestsScreenshotTest {
@get:Rule
val compose = createComposeRule()
/** The ordinary page: every band occupied, so the three headings can be compared. */
@Test
fun `my requests across every state`() {
capture("requests-mine", RequestsUiState(requests = sampleRequests, loadingRequests = false))
}
/** Nothing asked for yet. The empty state has a control, so focus has somewhere to go. */
@Test
fun `nothing requested yet`() {
capture("requests-mine-empty", RequestsUiState(loadingRequests = false))
}
/** The first load. Skeletons rather than a spinner, in the shape of the real cards. */
@Test
fun `loading the first list`() {
capture("requests-mine-loading", RequestsUiState(loadingRequests = true))
}
@Test
fun `the list could not be loaded`() {
capture(
"requests-mine-error",
RequestsUiState(
loadingRequests = false,
requestsError = "Memby could not reach the server. Check the connection and try again.",
),
)
}
/** Search results, with every state a candidate can be in showing at once. */
@Test
fun `search results`() {
capture(
"requests-discover",
RequestsUiState(
tab = RequestsTab.DISCOVER,
loadingRequests = false,
query = "dune",
hasSearched = true,
candidates = sampleCandidates,
),
)
}
/** A request in flight: only the pressed card shows it, the rest of the pane stays live. */
@Test
fun `a request being submitted`() {
capture(
"requests-discover-submitting",
RequestsUiState(
tab = RequestsTab.DISCOVER,
loadingRequests = false,
query = "dune",
hasSearched = true,
candidates = sampleCandidates,
submitting = setOf(candidateKey("movie", 693134)),
),
)
}
/** The keyboard with nothing typed. This is the pane's opening frame on every visit. */
@Test
fun `discover before anything is typed`() {
capture(
"requests-discover-empty",
RequestsUiState(tab = RequestsTab.DISCOVER, loadingRequests = false),
)
}
@Test
fun `a search that found nothing`() {
capture(
"requests-discover-no-matches",
RequestsUiState(
tab = RequestsTab.DISCOVER,
loadingRequests = false,
query = "qqqqq",
hasSearched = true,
),
)
}
/**
* Permission withdrawn while the page was open. Worth a capture because an empty list
* and "you are not allowed to ask" must not look like the same page.
*/
@Test
fun `requests no longer permitted`() {
capture(
"requests-not-allowed",
RequestsUiState(loadingRequests = false, allowed = false),
)
}
/**
* The way in. Captured beside the page because the entry and the permission are one
* feature: this pair is what shows whether the entry is hidden for somebody who may not
* request, rather than dimmed.
*/
@Test
fun `user menu offering requests`() {
capturePicker("requests-user-menu", showRequests = true)
}
@Test
fun `user menu for a viewer who may not request`() {
capturePicker("requests-user-menu-hidden", showRequests = false)
}
private fun capturePicker(name: String, showRequests: Boolean) {
compose.setContent {
MembyTheme {
UserSwitcherOverlay(
profiles = listOf(
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
EmbyProfile("b", "https://memby.example", "t", "u2", "Charlotte"),
),
activeProfileId = "a",
onProfileSelected = {},
onManageProfiles = {},
onDismiss = {},
alertCount = 2,
onOpenAlerts = {},
showRequests = showRequests,
onOpenRequests = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/requests/$name.png")
}
private fun capture(name: String, state: RequestsUiState) {
compose.setContent {
MembyTheme {
RequestsScreen(
state = state,
navigationFocusRequester = FocusRequester(),
contentFocusRequester = FocusRequester(),
onSelectTab = {},
onQueryChanged = {},
onAppendToQuery = {},
onBackspace = {},
onClearQuery = {},
onRequest = {},
onRemove = {},
onOpenItem = {},
onRetry = {},
onExit = {},
// No network in a screenshot test: this is the artwork-free fallback,
// which is the case worth looking at anyway.
posterUrlFor = { null },
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/requests/$name.png")
}
}
private val sampleRequests = listOf(
GatewayMediaRequestItem(
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
requestedAt = "2026-08-02T19:20:00Z", status = RequestStatus.AVAILABLE,
statusLabel = "Available", statusDetail = "Ready to watch now", embyItemId = "emby-1",
),
GatewayMediaRequestItem(
mediaType = "series", foreignId = 121361, title = "Silo", year = 2023,
requestedAt = "2026-08-01T08:05:00Z", status = RequestStatus.PROCESSING,
statusLabel = "Processing", statusDetail = "Searching for a copy",
),
GatewayMediaRequestItem(
mediaType = "movie", foreignId = 533535, title = "The Thursday Murder Club", year = 2026,
requestedAt = "2026-07-28T21:44:00Z", status = RequestStatus.PENDING,
statusLabel = "Pending", statusDetail = "Waiting for release",
),
GatewayMediaRequestItem(
mediaType = "series", foreignId = 94997, title = "A Show Nobody Is Tracking Any More",
year = 2022, requestedAt = "2026-06-11T12:00:00Z", status = RequestStatus.UNAVAILABLE,
statusLabel = "Unavailable", statusDetail = "No longer being tracked",
),
)
private val sampleCandidates = listOf(
GatewayRequestCandidate(
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
status = RequestStatus.REQUESTABLE, statusLabel = "Request",
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 438631, title = "Dune", year = 2021,
status = RequestStatus.AVAILABLE, statusLabel = "Available", inLibrary = true,
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 331138, title = "Dune: Prophecy", year = 2024,
status = RequestStatus.REQUESTED, statusLabel = "Requested", mine = true,
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 841598, title = "Dune: Part Three", year = 2027,
status = RequestStatus.PENDING, statusLabel = "Pending", alreadyAdded = true,
),
)
+2
View File
@@ -144,7 +144,9 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
v1.Handle("POST /v1/requests", s.authed(s.handleRequest))
v1.Handle("DELETE /v1/requests/{mediaType}/{foreignId}", s.authed(s.handleDeleteRequest))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("PUT /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
v1.Handle("DELETE /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
+7
View File
@@ -142,5 +142,12 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
// season has to reach a set that is already switched on, without anybody doing
// anything.
"theme": themeStatus(s.themeFor(r.Context(), sess)),
// Whether this viewer may ask the household for titles. Per person rather than per
// household, so it cannot ride the feature map beside it: the allowlist is the
// operator's decision about one account, and every television is polling this
// anyway. It is what keeps the Requests entry out of the switcher for everybody
// else — the handlers refuse regardless, but a menu item that only ever produces a
// refusal is worse than no menu item.
"requests": map[string]any{"allowed": s.requestAllowed(r, sess)},
})
}
+47
View File
@@ -232,3 +232,50 @@ func hasRadarrCover(images []radarr.Image, coverType string) bool {
}
return false
}
const radarrMovieCacheKey = "radarr:movies:v1"
// radarrMovieCatalogue is Radarr's whole movie list, cached the way sonarrSeriesCatalogue is
// and for the same reason: the request page needs the state of every title one viewer has
// ever asked for, and per-title lookups would be a round trip per card on a page somebody is
// waiting in front of. Shared across the household, because Radarr's catalogue is.
//
// Failure degrades to asking Radarr directly — a cache that is down costs latency, never the
// answer.
func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, error) {
if s.radarr == nil {
return nil, fmt.Errorf("radarr: not configured")
}
if movies := s.cachedRadarrMovies(ctx); movies != nil {
return movies, nil
}
s.radarrMu.Lock()
defer s.radarrMu.Unlock()
if movies := s.cachedRadarrMovies(ctx); movies != nil {
return movies, nil
}
movies, err := s.radarr.Movies(ctx)
if err != nil {
return nil, err
}
if body, marshalErr := json.Marshal(movies); marshalErr == nil {
if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil {
s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr)
}
}
return movies, nil
}
func (s *Server) cachedRadarrMovies(ctx context.Context) []radarr.Movie {
raw, err := s.cache.Get(ctx, radarrMovieCacheKey)
if err != nil {
return nil
}
var movies []radarr.Movie
if json.Unmarshal(raw, &movies) != nil {
return nil
}
return movies
}
+81 -5
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
@@ -24,6 +25,21 @@ type requestCandidate struct {
PosterURL string `json:"posterUrl,omitempty"`
AlreadyAdded bool `json:"alreadyAdded"`
InLibrary bool `json:"inLibrary"`
// What pressing this card would mean, said once by the server so the television never
// has to work it out from three booleans and get a different answer than the page next
// door. Mine is this viewer's own ask, which alreadyAdded cannot distinguish — the
// household adding a film is not the same as you having asked for it.
Status string `json:"status"`
StatusLabel string `json:"statusLabel"`
Mine bool `json:"mine"`
// Released feeds the status rule and is kept on the wire for the same reason the
// lifecycle slugs are: it is evidence, and a later build may want to word it better.
Released bool `json:"released"`
// hasFile is the *arr's own answer about media on disk, which is a different claim from
// InLibrary — Radarr can hold a downloaded film Emby has not imported yet. Unexported
// because it only feeds the status rule; the television is told the verdict, not the
// evidence behind it.
hasFile bool
}
type requestLookupResponse struct {
@@ -73,6 +89,10 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
MediaType: "movie", ForeignID: movie.TMDBID, Title: movie.Title,
Year: movie.Year, Overview: movie.Overview,
PosterURL: radarrCoverURL(movie.Images, "poster"), AlreadyAdded: movie.ID > 0,
// Radarr's lookup fills these in for a title it already tracks; for one it
// does not, hasFile is false and the status rule never reads Released.
hasFile: movie.HasFile,
Released: movieReleased(movie.Status),
})
}
}()
@@ -94,6 +114,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
MediaType: "series", ForeignID: show.TVDBID, Title: show.Title,
Year: show.Year, Overview: show.Overview,
PosterURL: sonarrCoverURL(show.Images, "poster"), AlreadyAdded: show.ID > 0,
Released: seriesReleased(show.Status, show.NextAiring, time.Now()),
})
}
}()
@@ -114,12 +135,32 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
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]
// Which of these the viewer has already asked for themselves. A failure here costs the
// "Requested" wording and nothing else, so it is not allowed to fail the search.
mine := map[string]bool{}
if stored, err := s.store.MediaRequests(r.Context(), sess.EmbyUserID); err == nil {
for _, req := range stored {
mine[req.MediaType+":"+strconv.Itoa(req.ForeignID)] = true
}
} else {
s.loggerFor(r.Context()).Warn("own requests unavailable for lookup", "error", err)
}
for index := range candidates {
candidate := &candidates[index]
if candidate.MediaType == "movie" {
candidate.InLibrary = moviesInLibrary[candidate.ForeignID]
} else {
candidate.InLibrary = seriesInLibrary[candidate.ForeignID]
}
candidate.Mine = mine[candidate.MediaType+":"+strconv.Itoa(candidate.ForeignID)]
candidate.Status = lookupStatusFor(RequestSubject{
Tracked: candidate.AlreadyAdded,
HasFile: candidate.hasFile,
InLibrary: candidate.InLibrary,
Released: candidate.Released,
}, candidate.Mine)
candidate.StatusLabel = requestStatusLabel(candidate.Status)
}
sort.SliceStable(candidates, func(i, j int) bool {
return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title)
@@ -213,6 +254,11 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
// a connection reset. If the first request already added it, the retry is the
// same successful action rather than an error shown to the viewer.
req.Title = movie.Title
// Still recorded as this viewer's ask. The household having the film already
// is not the same as them never having asked for it, and their page is the
// only place that distinction is kept.
s.recordMediaRequest(r.Context(), sess, req, movie.Year,
radarrCoverURL(movie.Images, "poster"))
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
return
@@ -225,6 +271,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
return
}
req.Title = added.Title
s.recordMediaRequest(r.Context(), sess, req, added.Year,
radarrCoverURL(added.Images, "poster"))
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
@@ -247,6 +295,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
}
if show.ID > 0 {
req.Title = show.Title
s.recordMediaRequest(r.Context(), sess, req, show.Year,
sonarrCoverURL(show.Images, "poster"))
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
return
@@ -259,6 +309,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
return
}
req.Title = added.Title
s.recordMediaRequest(r.Context(), sess, req, added.Year,
sonarrCoverURL(added.Images, "poster"))
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
@@ -272,6 +324,30 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
writeError(w, http.StatusNotFound, "title was not found")
}
// recordMediaRequest writes down who asked, which is the one thing Radarr and Sonarr do not
// keep. It is deliberately best-effort: the title has already been added by the time this
// runs, so failing the response here would tell a viewer their request did not work when it
// did. The cost of a lost write is that the ask is missing from their own page, which is
// recoverable by asking again — the cost of the opposite is a viewer requesting it twice.
func (s *Server) recordMediaRequest(
ctx context.Context, sess store.Session, req requestPayload, year int, posterURL string,
) {
if s.store == nil || sess.EmbyUserID == "" {
return
}
err := s.store.SaveMediaRequest(ctx, sess.EmbyUserID, store.MediaRequest{
MediaType: req.MediaType,
ForeignID: req.ForeignID,
Title: req.Title,
Year: year,
PosterURL: posterURL,
})
if err != nil {
s.loggerFor(ctx).Warn("media request not recorded",
"type", req.MediaType, "foreign_id", req.ForeignID, "error", err)
}
}
func (s *Server) logMediaRequest(
ctx context.Context, req requestPayload, outcome string, err error,
) {
+243
View File
@@ -0,0 +1,243 @@
package api
import (
"context"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// myRequest is one card on the viewer's own page: what they asked for, and what has become
// of it. The status half is derived per read and never stored — see the schema comment on
// media_requests.
type myRequest struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
PosterURL string `json:"posterUrl,omitempty"`
RequestedAt string `json:"requestedAt"`
Status string `json:"status"`
StatusLabel string `json:"statusLabel"`
StatusDetail string `json:"statusDetail"`
// EmbyItemID is set once Emby has imported the title, so the card can open the ordinary
// detail page instead of being a dead end at the moment it finally becomes watchable.
EmbyItemID string `json:"embyItemId,omitempty"`
}
type myRequestsResponse struct {
Requests []myRequest `json:"requests"`
// Allowed rides the response so a television that reached this page as permission was
// withdrawn is told, rather than reading an empty list as "you have asked for nothing".
Allowed bool `json:"allowed"`
}
func (s *Server) handleMyRequests(w http.ResponseWriter, r *http.Request, sess store.Session) {
if !s.requestAllowed(r, sess) {
writeError(w, http.StatusForbidden, "media requests are not enabled for this user")
return
}
stored, err := s.store.MediaRequests(r.Context(), sess.EmbyUserID)
if err != nil {
s.loggerFor(r.Context()).Error("media request list failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read your requests")
return
}
writeJSON(w, http.StatusOK, myRequestsResponse{
Requests: s.decorateRequests(r.Context(), stored),
Allowed: true,
})
}
// decorateRequests turns stored asks into cards by asking the two catalogues and the
// library what has become of each.
//
// The three lookups run concurrently and every one of them is allowed to fail: a request
// whose state cannot be established falls back to "requested", which is the honest answer —
// somebody asked and we cannot currently say more. A page that errored because Radarr was
// restarting would be a page that is broken exactly when a viewer wants to know why their
// title has not arrived.
func (s *Server) decorateRequests(
ctx context.Context, stored []store.MediaRequest,
) []myRequest {
if len(stored) == 0 {
return []myRequest{}
}
movieIDs, seriesIDs := []int{}, []int{}
for _, req := range stored {
if req.MediaType == "series" {
seriesIDs = append(seriesIDs, req.ForeignID)
} else {
movieIDs = append(movieIDs, req.ForeignID)
}
}
var (
wg sync.WaitGroup
movies = map[int]requestCatalogueEntry{}
series = map[int]requestCatalogueEntry{}
moviesInLibrary = map[int]bool{}
seriesInLibrary = map[int]bool{}
movieItemIDs = map[int]string{}
seriesItemIDs = map[int]string{}
)
now := time.Now()
if len(movieIDs) > 0 && s.radarr != nil {
wg.Add(1)
go func() {
defer wg.Done()
catalogue, err := s.radarrMovieCatalogue(ctx)
if err != nil {
s.loggerFor(ctx).Warn("radarr catalogue unavailable for requests", "error", err)
return
}
for _, movie := range catalogue {
if movie.TMDBID == 0 {
continue
}
movies[movie.TMDBID] = requestCatalogueEntry{
tracked: true,
hasFile: movie.HasFile,
released: movieReleased(movie.Status),
overview: movie.Overview,
poster: radarrCoverURL(movie.Images, "poster"),
}
}
}()
}
if len(seriesIDs) > 0 && s.sonarr != nil {
wg.Add(1)
go func() {
defer wg.Done()
catalogue, err := s.sonarrSeriesCatalogue(ctx)
if err != nil {
s.loggerFor(ctx).Warn("sonarr catalogue unavailable for requests", "error", err)
return
}
for _, show := range catalogue {
if show.TVDBID == 0 {
continue
}
series[show.TVDBID] = requestCatalogueEntry{
tracked: true,
released: seriesReleased(show.Status, show.NextAiring, now),
overview: show.Overview,
poster: sonarrCoverURL(show.Images, "poster"),
}
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
var err error
moviesInLibrary, movieItemIDs, err = s.libraryPresence(ctx, "Tmdb", movieIDs)
if err != nil {
s.loggerFor(ctx).Warn("library presence unavailable for requests", "error", err)
}
seriesInLibrary, seriesItemIDs, err = s.libraryPresence(ctx, "Tvdb", seriesIDs)
if err != nil {
s.loggerFor(ctx).Warn("library presence unavailable for requests", "error", err)
}
}()
wg.Wait()
cards := make([]myRequest, 0, len(stored))
for _, req := range stored {
entry, inLibrary, itemID := movies[req.ForeignID], moviesInLibrary[req.ForeignID], movieItemIDs[req.ForeignID]
if req.MediaType == "series" {
entry, inLibrary, itemID = series[req.ForeignID], seriesInLibrary[req.ForeignID], seriesItemIDs[req.ForeignID]
}
status := RequestStatusRequested
// Only claim a state when something actually answered. With every catalogue down,
// "requested" is all that is known and is what the card must say.
if entry.tracked || inLibrary {
status = requestStatusFor(RequestSubject{
Tracked: entry.tracked,
HasFile: entry.hasFile,
InLibrary: inLibrary,
Released: entry.released,
})
}
poster := req.PosterURL
if poster == "" {
poster = entry.poster
}
cards = append(cards, myRequest{
MediaType: req.MediaType,
ForeignID: req.ForeignID,
Title: req.Title,
Year: req.Year,
Overview: entry.overview,
PosterURL: poster,
RequestedAt: req.RequestedAt.UTC().Format(time.RFC3339),
Status: status,
StatusLabel: requestStatusLabel(status),
StatusDetail: requestStatusDetail(status, req.MediaType),
EmbyItemID: itemID,
})
}
return cards
}
// requestCatalogueEntry is what one *arr knows about a title, in the shape the status rule
// and the card between them need.
type requestCatalogueEntry struct {
tracked bool
hasFile bool
released bool
overview string
poster string
}
// libraryPresence answers both "has Emby imported this" and "under which item id", so a
// request that has become watchable can open its own detail page. The id is the reason this
// is not simply LibraryContainsProviderIDs.
func (s *Server) libraryPresence(
ctx context.Context, provider string, ids []int,
) (map[int]bool, map[int]string, error) {
present, itemIDs := map[int]bool{}, map[int]string{}
if len(ids) == 0 {
return present, itemIDs, nil
}
found, err := s.store.LibraryProviderItemIDs(ctx, provider, ids)
if err != nil {
return present, itemIDs, err
}
for id, itemID := range found {
present[id] = true
itemIDs[id] = itemID
}
return present, itemIDs, nil
}
// handleDeleteRequest removes a title from the viewer's own page.
//
// It deliberately leaves Radarr and Sonarr alone: the household may well be part-way
// through downloading it, and somebody else may have asked for the same thing. This says
// "stop listing this among mine", which is the only claim one viewer's page can make.
func (s *Server) handleDeleteRequest(w http.ResponseWriter, r *http.Request, sess store.Session) {
if !s.requestAllowed(r, sess) {
writeError(w, http.StatusForbidden, "media requests are not enabled for this user")
return
}
mediaType := strings.TrimSpace(r.PathValue("mediaType"))
foreignID, err := strconv.Atoi(strings.TrimSpace(r.PathValue("foreignId")))
if err != nil || foreignID <= 0 {
writeError(w, http.StatusBadRequest, "a media type and foreign id are required")
return
}
if err := s.store.DeleteMediaRequest(r.Context(), sess.EmbyUserID, mediaType, foreignID); err != nil {
s.loggerFor(r.Context()).Error("media request delete failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not remove that request")
return
}
s.loggerFor(r.Context()).Info("media request removed", "type", mediaType, "foreign_id", foreignID)
w.WriteHeader(http.StatusNoContent)
}
+170
View File
@@ -0,0 +1,170 @@
package api
import (
"strings"
"time"
)
// What a request is doing, as a slug the television renders.
//
// These are the states Radarr and Sonarr can actually answer for. Neither has an approval
// workflow — an added movie is simply added — so there is deliberately no "approved" or
// "declined" here: inventing one would put a word on a card that nothing behind it can ever
// change. The client renders an unknown slug in its neutral treatment (see requestStatusTone
// on the television), so if a household ever puts an approval layer in front of the *arrs,
// those states can be added here and reach existing builds without an app release.
const (
// The household has it. Either Emby has imported it or the *arr reports a file.
RequestStatusAvailable = "available"
// Accepted and being worked on: released, monitored, no file yet.
RequestStatusProcessing = "processing"
// Accepted, but there is nothing to fetch yet — unreleased, or still only in cinemas.
RequestStatusPending = "pending"
// Recorded by Memby, but the *arr could not be asked. The honest fallback: we know
// somebody asked and nothing more.
RequestStatusRequested = "requested"
// Recorded by Memby and the *arr no longer has it, so somebody removed it downstream.
RequestStatusUnavailable = "unavailable"
// Search only: nothing has it and nobody has asked, so the button does something.
RequestStatusRequestable = "requestable"
)
// lookupStatusFor answers what a search result is, which is a different question from what
// a stored request is doing: a candidate nothing tracks is the ordinary case here — it is
// the whole point of searching — where in a request list it would mean somebody had removed
// it. So the two rules are separate rather than one rule with a flag, and only this one can
// return "requestable".
//
// Being this viewer's own ask outranks the household merely having added it: "Requested"
// tells them they already did this, which is the thing they most need to know before
// pressing a button a second time. It does not outrank availability — a title that is in
// the library is watchable now, and that is better news.
func lookupStatusFor(subject RequestSubject, mine bool) string {
switch {
case subject.InLibrary || subject.HasFile:
return RequestStatusAvailable
case mine:
return RequestStatusRequested
case !subject.Tracked:
return RequestStatusRequestable
case !subject.Released:
return RequestStatusPending
default:
return RequestStatusProcessing
}
}
// RequestSubject is what the *arr and the library between them know about one title, in
// the narrow shape requestStatusFor needs. Keeping it free of Radarr and Sonarr types is
// what lets one rule answer for both catalogues and be tested without either.
type RequestSubject struct {
// Tracked is whether the *arr still holds the title at all.
Tracked bool
// HasFile is the *arr's own answer about media on disk. Sonarr's series list does not
// report this, so a series leaves it false and leans on InLibrary.
HasFile bool
// InLibrary is whether Emby has imported it, matched on the catalogue's provider id.
InLibrary bool
// Released is whether there is anything to fetch yet. A film not yet on digital and a
// series whose first episode has not aired are both false.
Released bool
}
// requestStatusFor is the whole rule, and it is ordered by how much each signal is worth.
//
// Availability wins over everything: a title the household can watch is available whether
// or not the *arr still tracks it, whether or not it was ever released on the date anybody
// recorded. Only then does absence from the *arr mean removal — checked before the release
// state, because an untracked title's release date says nothing about a request nobody is
// working on any more.
func requestStatusFor(subject RequestSubject) string {
switch {
case subject.InLibrary || subject.HasFile:
return RequestStatusAvailable
case !subject.Tracked:
return RequestStatusUnavailable
case !subject.Released:
return RequestStatusPending
default:
return RequestStatusProcessing
}
}
// movieReleased reads Radarr's own status word rather than comparing dates.
//
// Radarr already decides this, applying the household's minimum-availability setting, and
// a date comparison here would disagree with it for exactly the titles that are marginal —
// which are the ones somebody is watching their request page for. "announced" and
// "incinemas" are the two that mean there is nothing to fetch; anything else, including a
// word this build has never seen, is treated as released so a new Radarr vocabulary
// degrades to "processing" rather than parking a request on "pending" for ever.
func movieReleased(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "announced", "incinemas":
return false
default:
return true
}
}
// seriesReleased asks whether any of the show exists yet.
//
// Sonarr's series list carries no per-episode file information, so the question it can
// answer is narrower than Radarr's: a show whose only airing is in the future has nothing
// to fetch. "upcoming" is Sonarr's own word for that; a next-airing date in the future with
// no library presence is the same thing said with a timestamp, which is what a show added
// before its premiere looks like.
func seriesReleased(status string, nextAiring *time.Time, now time.Time) bool {
if strings.EqualFold(strings.TrimSpace(status), "upcoming") {
return false
}
if nextAiring != nil && nextAiring.After(now) &&
strings.EqualFold(strings.TrimSpace(status), "") {
return false
}
return true
}
// requestStatusLabel is the wording the card shows, sent from here rather than derived on
// the television — the MembyAirLabel precedent. A build that predates a state renders the
// label it was handed instead of falling back to a slug.
func requestStatusLabel(status string) string {
switch status {
case RequestStatusAvailable:
return "Available"
case RequestStatusProcessing:
return "Processing"
case RequestStatusPending:
return "Pending"
case RequestStatusUnavailable:
return "Unavailable"
case RequestStatusRequestable:
return "Request"
default:
return "Requested"
}
}
// requestStatusDetail is the quiet second line: what the state means for the viewer, in
// plain language, rather than a repeat of the word above it.
func requestStatusDetail(status, mediaType string) string {
thing := "film"
if mediaType == "series" {
thing = "series"
}
switch status {
case RequestStatusAvailable:
return "Ready to watch now"
case RequestStatusProcessing:
return "Searching for a copy"
case RequestStatusPending:
if thing == "series" {
return "Waiting for it to air"
}
return "Waiting for release"
case RequestStatusUnavailable:
return "No longer being tracked"
default:
return "Waiting on the " + thing + " service"
}
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"testing"
"time"
)
func TestRequestStatusPrefersAvailabilityOverEverything(t *testing.T) {
// A title the household can watch is available whether or not the *arr still tracks it
// and whether or not anything thinks it has been released. Being watchable is the
// strongest fact there is about a request, so nothing below may override it.
cases := []struct {
name string
subject RequestSubject
}{
{"in library, untracked, unreleased", RequestSubject{InLibrary: true}},
{"has file but not imported", RequestSubject{Tracked: true, HasFile: true}},
{"in library and still being worked on", RequestSubject{
Tracked: true, InLibrary: true, Released: true,
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := requestStatusFor(tc.subject); got != RequestStatusAvailable {
t.Fatalf("expected %q, got %q", RequestStatusAvailable, got)
}
})
}
}
func TestRequestStatusReportsRemovalBeforeReleaseState(t *testing.T) {
// An untracked title's release date says nothing: nobody is working on it either way,
// so "unavailable" must win over "pending". Getting this the wrong way round would park
// a removed request on "waiting for release" for ever.
got := requestStatusFor(RequestSubject{Tracked: false, Released: false})
if got != RequestStatusUnavailable {
t.Fatalf("expected %q for an untracked title, got %q", RequestStatusUnavailable, got)
}
}
func TestRequestStatusSeparatesPendingFromProcessing(t *testing.T) {
pending := requestStatusFor(RequestSubject{Tracked: true, Released: false})
if pending != RequestStatusPending {
t.Fatalf("expected %q, got %q", RequestStatusPending, pending)
}
processing := requestStatusFor(RequestSubject{Tracked: true, Released: true})
if processing != RequestStatusProcessing {
t.Fatalf("expected %q, got %q", RequestStatusProcessing, processing)
}
}
func TestLookupStatusOffersRequestableOnlyWhenNothingHasIt(t *testing.T) {
got := lookupStatusFor(RequestSubject{}, false)
if got != RequestStatusRequestable {
t.Fatalf("expected %q, got %q", RequestStatusRequestable, got)
}
// The request list rule must never produce it: there, an untracked title is one somebody
// removed rather than one nobody has asked for.
if requestStatusFor(RequestSubject{}) == RequestStatusRequestable {
t.Fatal("requestStatusFor must not return requestable")
}
}
func TestLookupStatusPutsOwnRequestAboveHouseholdButBelowAvailability(t *testing.T) {
// Somebody needs to be told they already asked, before being told the household happens
// to track it — that is what stops them pressing the button again.
mine := lookupStatusFor(RequestSubject{Tracked: true, Released: true}, true)
if mine != RequestStatusRequested {
t.Fatalf("expected %q for the viewer's own ask, got %q", RequestStatusRequested, mine)
}
// But being watchable now is better news than having asked.
available := lookupStatusFor(RequestSubject{InLibrary: true}, true)
if available != RequestStatusAvailable {
t.Fatalf("expected %q, got %q", RequestStatusAvailable, available)
}
}
func TestMovieReleasedTreatsUnknownStatusAsReleased(t *testing.T) {
// A Radarr vocabulary this build has never seen must degrade to "processing", not park
// the request on "pending" for ever.
for _, status := range []string{"released", "deleted", "somethingNew", ""} {
if !movieReleased(status) {
t.Fatalf("expected %q to count as released", status)
}
}
for _, status := range []string{"announced", "inCinemas", "INCINEMAS", " announced "} {
if movieReleased(status) {
t.Fatalf("expected %q to count as unreleased", status)
}
}
}
func TestSeriesReleasedReadsUpcomingAndFutureAirings(t *testing.T) {
now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
later := now.Add(48 * time.Hour)
earlier := now.Add(-48 * time.Hour)
if seriesReleased("upcoming", nil, now) {
t.Fatal("an upcoming series has nothing to fetch yet")
}
if seriesReleased("", &later, now) {
t.Fatal("a series whose only airing is in the future has nothing to fetch yet")
}
if !seriesReleased("continuing", &later, now) {
t.Fatal("a continuing series with a future episode is still fetchable now")
}
if !seriesReleased("", &earlier, now) {
t.Fatal("a series that has already aired is fetchable")
}
}
func TestRequestStatusLabelAndDetailCoverEveryState(t *testing.T) {
// Every state must carry its own wording: the television renders what it is handed, so
// a state that fell through to the default would show "Requested" on a card that is
// actually available.
states := []string{
RequestStatusAvailable, RequestStatusProcessing, RequestStatusPending,
RequestStatusUnavailable, RequestStatusRequestable, RequestStatusRequested,
}
seen := map[string]bool{}
for _, state := range states {
label := requestStatusLabel(state)
if label == "" {
t.Fatalf("state %q has no label", state)
}
if seen[label] {
t.Fatalf("state %q reuses the label %q", state, label)
}
seen[label] = true
}
if requestStatusDetail(RequestStatusPending, "series") ==
requestStatusDetail(RequestStatusPending, "movie") {
t.Fatal("a pending series and a pending film are waiting for different things")
}
}
+13
View File
@@ -221,3 +221,16 @@ func (c *Client) do(req *http.Request, out any) error {
}
return nil
}
// Movies returns Radarr's whole catalogue.
//
// The request page needs the current state of every title a viewer has ever asked for, and
// asking Radarr per title would be one round trip per card. One catalogue read answers them
// all; the caller caches it.
func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
var movies []Movie
if err := c.get(ctx, "/api/v3/movie", &movies); err != nil {
return nil, err
}
return movies, nil
}
+46
View File
@@ -180,6 +180,52 @@ func (s *Store) LibraryContainsProviderIDs(
return found, rows.Err()
}
// LibraryProviderItemIDs is LibraryContainsProviderIDs with the answer the request page
// needs: not only whether Emby has the title, but which item it is.
//
// That id is what lets a request that has finally downloaded stop being a status card and
// become something a viewer can press. Ordering by item_id keeps the answer stable when a
// household holds the same title twice — a duplicate import, or a remake sharing an id in
// bad metadata — so a card does not point at a different copy between two reads.
func (s *Store) LibraryProviderItemIDs(
ctx context.Context, provider string, ids []int,
) (map[int]string, error) {
found := map[int]string{}
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))
}
}
if len(values) == 0 {
return found, nil
}
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON ((payload->'ProviderIds'->>$1)::int)
(payload->'ProviderIds'->>$1)::int, id
FROM library_items
WHERE payload->'ProviderIds'->>$1 = ANY($2::text[])
ORDER BY (payload->'ProviderIds'->>$1)::int, id`, provider, values)
if err != nil {
return nil, fmt.Errorf("store: library provider item ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
id int
itemID string
)
if err := rows.Scan(&id, &itemID); err != nil {
return nil, err
}
found[id] = itemID
}
return found, rows.Err()
}
// 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
// that knows it.
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"context"
"fmt"
"strings"
"time"
)
// MediaRequest is one viewer's ask, as recorded. It carries no status: see the schema
// comment on media_requests for why the state is derived per read rather than stored.
type MediaRequest struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
PosterURL string `json:"posterUrl,omitempty"`
RequestedAt time.Time `json:"requestedAt"`
}
// MediaRequestLimit caps what one viewer's page will read back. A household that has been
// asking for things for two years should not turn the page into an unbounded query, and
// nobody scrolls past the most recent hundred by remote.
const MediaRequestLimit = 100
// SaveMediaRequest records an ask, or refreshes one already held.
//
// The repeat is deliberately an update rather than a no-op: asking again is how somebody
// says they still want it, and the page is ordered by when they asked.
func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRequest) error {
userID = strings.TrimSpace(userID)
if userID == "" || req.ForeignID <= 0 {
return fmt.Errorf("store: media request needs a user and a foreign id")
}
_, err := s.pool.Exec(ctx, `
INSERT INTO media_requests
(emby_user_id, media_type, foreign_id, title, year, poster_url, requested_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (emby_user_id, media_type, foreign_id) DO UPDATE
SET title = EXCLUDED.title,
year = EXCLUDED.year,
poster_url = EXCLUDED.poster_url,
requested_at = now()`,
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL)
if err != nil {
return fmt.Errorf("store: save media request: %w", err)
}
return nil
}
// MediaRequests returns one viewer's asks, most recent first.
func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaRequest, error) {
rows, err := s.pool.Query(ctx, `
SELECT media_type, foreign_id, title, year, poster_url, requested_at
FROM media_requests
WHERE emby_user_id = $1
ORDER BY requested_at DESC
LIMIT $2`, strings.TrimSpace(userID), MediaRequestLimit)
if err != nil {
return nil, fmt.Errorf("store: read media requests: %w", err)
}
defer rows.Close()
requests := []MediaRequest{}
for rows.Next() {
var req MediaRequest
if err := rows.Scan(
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL, &req.RequestedAt,
); err != nil {
return nil, fmt.Errorf("store: scan media request: %w", err)
}
requests = append(requests, req)
}
return requests, rows.Err()
}
// DeleteMediaRequest removes a viewer's ask from their own page.
//
// It deliberately does not touch Radarr or Sonarr. The title may well have been downloaded
// by now, and other people may have asked for it too — this only says the viewer no longer
// wants it listed among theirs.
func (s *Store) DeleteMediaRequest(
ctx context.Context, userID, mediaType string, foreignID int,
) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM media_requests
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3`,
strings.TrimSpace(userID), mediaType, foreignID)
if err != nil {
return fmt.Errorf("store: delete media request: %w", err)
}
return nil
}
+31
View File
@@ -508,3 +508,34 @@ CREATE TABLE IF NOT EXISTS downloaded_subtitles (
);
CREATE INDEX IF NOT EXISTS downloaded_subtitles_item_idx ON downloaded_subtitles (item_id);
-- What a viewer has asked the household to get hold of.
--
-- Radarr and Sonarr are the things that actually fetch a title, and neither keeps any idea
-- of *who* wanted it: an added movie is an added movie. So this table is the only record of
-- authorship, and it is what makes "My requests" a per-person page rather than a list of
-- everything the household has ever added.
--
-- It deliberately stores no status. A request's state — waiting for a release, searching,
-- downloaded, in the library — is Radarr's and Sonarr's to answer and changes without
-- anybody touching Memby, so a status column here would be a second copy that is wrong
-- within the hour. What is stored is the identity (which title, from which catalogue) plus
-- enough metadata to draw the card before the *arr lookup returns; the state is derived per
-- request by requestStatusFor.
--
-- The primary key is (viewer, catalogue, id) rather than a serial, so asking twice for the
-- same film is the same request rather than two rows a viewer has to tell apart. The repeat
-- refreshes requested_at, because the second ask is the one they remember making.
CREATE TABLE IF NOT EXISTS media_requests (
emby_user_id TEXT NOT NULL,
media_type TEXT NOT NULL,
foreign_id INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
year INTEGER NOT NULL DEFAULT 0,
poster_url TEXT NOT NULL DEFAULT '',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, media_type, foreign_id)
);
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC);