0.2.58 - Requests module
This commit is contained in:
+5
-8
@@ -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
|
## 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.
|
- 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: 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.
|
- 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.
|
- 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
|
## 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: 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## 0.2.53 — 2026-08-11
|
||||||
- Fixed: Removed the home-screen message asking viewers to open the For You page.
|
- 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: 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: 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
|
## 0.2.52 — 2026-08-11
|
||||||
- Fixed: The last refreshed date in the Devices list used the incorrect date format.
|
- Fixed: The last refreshed date in the Devices list used the incorrect date format.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.57"
|
val defaultVersionName = "0.2.58"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -220,6 +220,20 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
private val snapshot: Settings
|
private val snapshot: Settings
|
||||||
get() = settings.current ?: observedSettings
|
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 {
|
init {
|
||||||
scope.launch { settings.settingsFlow.collect { observedSettings = it } }
|
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> {
|
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
|
return requireGateway().requestLookup(term.trim()).candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,6 +895,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
|
candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
|
||||||
): String {
|
): String {
|
||||||
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
||||||
|
requireRequestPermission()
|
||||||
return requireGateway().requestMedia(
|
return requireGateway().requestMedia(
|
||||||
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
|
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
|
||||||
mediaType = candidate.mediaType,
|
mediaType = candidate.mediaType,
|
||||||
@@ -881,6 +905,41 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
).title
|
).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
|
* 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
|
* 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 _installPermissionPrompt = MutableStateFlow(false)
|
||||||
private val _genreBrowserEnabled = MutableStateFlow(false)
|
private val _genreBrowserEnabled = MutableStateFlow(false)
|
||||||
private val _tvCalendarEnabled = MutableStateFlow(false)
|
private val _tvCalendarEnabled = MutableStateFlow(false)
|
||||||
|
private val _requestsAllowed = MutableStateFlow(false)
|
||||||
private val _gatewayVersion = MutableStateFlow("")
|
private val _gatewayVersion = MutableStateFlow("")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,9 +132,34 @@ class MaintenanceMonitor(
|
|||||||
*/
|
*/
|
||||||
val tvCalendarEnabled: StateFlow<Boolean> = _tvCalendarEnabled.asStateFlow()
|
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. */
|
/** Build reported by the connected gateway, for Settings → About. */
|
||||||
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
|
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.
|
* Alerts this television has already shown.
|
||||||
*
|
*
|
||||||
@@ -205,6 +231,7 @@ class MaintenanceMonitor(
|
|||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
_genreBrowserEnabled.value = false
|
_genreBrowserEnabled.value = false
|
||||||
_tvCalendarEnabled.value = false
|
_tvCalendarEnabled.value = false
|
||||||
|
setRequestsAllowed(false)
|
||||||
_gatewayVersion.value = ""
|
_gatewayVersion.value = ""
|
||||||
dismissAlert()
|
dismissAlert()
|
||||||
return@collectLatest
|
return@collectLatest
|
||||||
@@ -238,6 +265,7 @@ class MaintenanceMonitor(
|
|||||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||||
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
||||||
_tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true
|
_tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true
|
||||||
|
setRequestsAllowed(status.requests.allowed)
|
||||||
_gatewayVersion.value = status.gatewayVersion
|
_gatewayVersion.value = status.gatewayVersion
|
||||||
// Emby's state is reported even during maintenance: an
|
// Emby's state is reported even during maintenance: an
|
||||||
// operator taking Memby down while Emby is also unreachable
|
// operator taking Memby down while Emby is also unreachable
|
||||||
@@ -269,6 +297,7 @@ class MaintenanceMonitor(
|
|||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
_genreBrowserEnabled.value = false
|
_genreBrowserEnabled.value = false
|
||||||
_tvCalendarEnabled.value = false
|
_tvCalendarEnabled.value = false
|
||||||
|
setRequestsAllowed(false)
|
||||||
_gatewayVersion.value = ""
|
_gatewayVersion.value = ""
|
||||||
dismissAlert()
|
dismissAlert()
|
||||||
return@collectLatest
|
return@collectLatest
|
||||||
|
|||||||
@@ -192,8 +192,20 @@ data class GatewayServiceStatus(
|
|||||||
* with, which is the palette everything looked like before themes existed.
|
* with, which is the palette everything looked like before themes existed.
|
||||||
*/
|
*/
|
||||||
val theme: GatewayThemeStatus = GatewayThemeStatus(),
|
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. */
|
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayThemeStatus(
|
data class GatewayThemeStatus(
|
||||||
@@ -479,6 +491,45 @@ data class GatewayRequestCandidate(
|
|||||||
val posterUrl: String = "",
|
val posterUrl: String = "",
|
||||||
val alreadyAdded: Boolean = false,
|
val alreadyAdded: Boolean = false,
|
||||||
val inLibrary: 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
|
@Serializable
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ interface GatewayApi {
|
|||||||
@POST("v1/requests")
|
@POST("v1/requests")
|
||||||
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
|
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. */
|
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
|
||||||
@GET("v1/recommendations")
|
@GET("v1/recommendations")
|
||||||
suspend fun recommendations(): GatewayRows
|
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.LiveTv
|
||||||
import androidx.compose.material.icons.filled.Movie
|
import androidx.compose.material.icons.filled.Movie
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.PlaylistRemove
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
@@ -432,10 +433,22 @@ fun UserSwitcherOverlay(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
alertCount: Int = 0,
|
alertCount: Int = 0,
|
||||||
onOpenAlerts: () -> Unit = {},
|
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 profileIds = profiles.map(EmbyProfile::id)
|
||||||
val focusRequesters = remember(profileIds) {
|
val actionCount = userSwitcherActionCount(showRequests)
|
||||||
List(profiles.size + UserSwitcherActionCount) { FocusRequester() }
|
// 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 profileListState = remember(profileIds) { LazyListState() }
|
||||||
val focusScope = rememberCoroutineScope()
|
val focusScope = rememberCoroutineScope()
|
||||||
@@ -483,7 +496,7 @@ fun UserSwitcherOverlay(
|
|||||||
currentIndex = focusedIndex,
|
currentIndex = focusedIndex,
|
||||||
profileCount = profiles.size,
|
profileCount = profiles.size,
|
||||||
direction = direction,
|
direction = direction,
|
||||||
actionCount = UserSwitcherActionCount,
|
actionCount = actionCount,
|
||||||
)
|
)
|
||||||
focusedIndex = next
|
focusedIndex = next
|
||||||
profileFocusJob?.cancel()
|
profileFocusJob?.cancel()
|
||||||
@@ -567,13 +580,28 @@ fun UserSwitcherOverlay(
|
|||||||
},
|
},
|
||||||
onClick = onOpenAlerts,
|
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 = "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(
|
UserSwitcherAction(
|
||||||
label = "Manage users",
|
label = "Manage users",
|
||||||
icon = Icons.Default.Settings,
|
icon = Icons.Default.Settings,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequesters[profiles.size + 1])
|
.focusRequester(focusRequesters[manageIndex])
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
if (it.isFocused) focusedIndex = profiles.size + 1
|
if (it.isFocused) focusedIndex = manageIndex
|
||||||
},
|
},
|
||||||
onClick = onManageProfiles,
|
onClick = onManageProfiles,
|
||||||
)
|
)
|
||||||
@@ -581,8 +609,14 @@ fun UserSwitcherOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
@Composable
|
||||||
private fun UserSwitcherProfileItem(
|
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.airingNoticeFor
|
||||||
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
|
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
|
||||||
import com.ponzischeme89.memby.ui.calendar.CalendarScreen
|
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.GenreDiscoveryStrip
|
||||||
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
||||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||||
@@ -1925,6 +1928,9 @@ private fun HomeScreen(
|
|||||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||||
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
||||||
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.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
|
// 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
|
// so a ticking second never reaches the launcher. This is read here purely to decide
|
||||||
// which of the two top bars gets the strip.
|
// which of the two top bars gets the strip.
|
||||||
@@ -1975,6 +1981,7 @@ private fun HomeScreen(
|
|||||||
),
|
),
|
||||||
) + notificationState.notifications
|
) + notificationState.notifications
|
||||||
var showNotifications by remember { mutableStateOf(false) }
|
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
|
// 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
|
// 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.
|
// 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() }
|
val heroRowEntryFocusRequester = remember { FocusRequester() }
|
||||||
// Feature flags can remove the node that currently owns focus. Move the route first,
|
// 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.
|
// 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) {
|
LaunchedEffect(tvCalendarEnabled) {
|
||||||
if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) {
|
if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) {
|
||||||
selectedDestination = BrowseDestination.HOME
|
selectedDestination = BrowseDestination.HOME
|
||||||
@@ -2116,6 +2129,7 @@ private fun HomeScreen(
|
|||||||
showProfiles = false
|
showProfiles = false
|
||||||
userSwitcherVisible = false
|
userSwitcherVisible = false
|
||||||
showNotifications = false
|
showNotifications = false
|
||||||
|
showRequests = false
|
||||||
detailsItem = null
|
detailsItem = null
|
||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
quickMenuItem = null
|
quickMenuItem = null
|
||||||
@@ -2330,6 +2344,7 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
val journeyScreen = when {
|
val journeyScreen = when {
|
||||||
showSettings -> "settings"
|
showSettings -> "settings"
|
||||||
|
showRequests -> "requests"
|
||||||
showNotifications -> "notifications"
|
showNotifications -> "notifications"
|
||||||
userSwitcherVisible || showProfiles -> "profiles"
|
userSwitcherVisible || showProfiles -> "profiles"
|
||||||
detailsItem != null -> "details"
|
detailsItem != null -> "details"
|
||||||
@@ -3131,6 +3146,16 @@ private fun HomeScreen(
|
|||||||
notificationsLoading = false
|
notificationsLoading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showRequests = requestsAllowed,
|
||||||
|
onOpenRequests = {
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "requests", action = "open", screen = journeyScreen,
|
||||||
|
feature = "requests", target = "requests",
|
||||||
|
)
|
||||||
|
userSwitcherVisible = false
|
||||||
|
navigationExpanded = false
|
||||||
|
showRequests = true
|
||||||
|
},
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
userSwitcherVisible = false
|
userSwitcherVisible = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -3394,6 +3419,54 @@ private fun HomeScreen(
|
|||||||
onClose = { closeMyShow(false) },
|
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) {
|
if (showNotifications) {
|
||||||
// Reached from the user picker, so leaving it goes back to the rail rather than
|
// 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.
|
// 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
|
@Composable
|
||||||
private fun TvKeyboard(
|
internal fun TvKeyboard(
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
resultsEntry: FocusRequester,
|
resultsEntry: FocusRequester,
|
||||||
keyboardEntry: 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -144,7 +144,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
||||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
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/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("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("GET /v1/recommendations", s.authed(s.handleRecommendations))
|
||||||
v1.Handle("PUT /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
|
v1.Handle("PUT /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
|
||||||
v1.Handle("DELETE /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
|
v1.Handle("DELETE /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
|
||||||
|
|||||||
@@ -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
|
// season has to reach a set that is already switched on, without anybody doing
|
||||||
// anything.
|
// anything.
|
||||||
"theme": themeStatus(s.themeFor(r.Context(), sess)),
|
"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)},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,3 +232,50 @@ func hasRadarrCover(images []radarr.Image, coverType string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
@@ -24,6 +25,21 @@ type requestCandidate struct {
|
|||||||
PosterURL string `json:"posterUrl,omitempty"`
|
PosterURL string `json:"posterUrl,omitempty"`
|
||||||
AlreadyAdded bool `json:"alreadyAdded"`
|
AlreadyAdded bool `json:"alreadyAdded"`
|
||||||
InLibrary bool `json:"inLibrary"`
|
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 {
|
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,
|
MediaType: "movie", ForeignID: movie.TMDBID, Title: movie.Title,
|
||||||
Year: movie.Year, Overview: movie.Overview,
|
Year: movie.Year, Overview: movie.Overview,
|
||||||
PosterURL: radarrCoverURL(movie.Images, "poster"), AlreadyAdded: movie.ID > 0,
|
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,
|
MediaType: "series", ForeignID: show.TVDBID, Title: show.Title,
|
||||||
Year: show.Year, Overview: show.Overview,
|
Year: show.Year, Overview: show.Overview,
|
||||||
PosterURL: sonarrCoverURL(show.Images, "poster"), AlreadyAdded: show.ID > 0,
|
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",
|
s.loggerFor(r.Context()).Warn("request library status unavailable",
|
||||||
"movie_error", movieErr, "series_error", seriesErr)
|
"movie_error", movieErr, "series_error", seriesErr)
|
||||||
}
|
}
|
||||||
for index := range candidates {
|
// Which of these the viewer has already asked for themselves. A failure here costs the
|
||||||
if candidates[index].MediaType == "movie" {
|
// "Requested" wording and nothing else, so it is not allowed to fail the search.
|
||||||
candidates[index].InLibrary = moviesInLibrary[candidates[index].ForeignID]
|
mine := map[string]bool{}
|
||||||
} else {
|
if stored, err := s.store.MediaRequests(r.Context(), sess.EmbyUserID); err == nil {
|
||||||
candidates[index].InLibrary = seriesInLibrary[candidates[index].ForeignID]
|
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 {
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||||||
return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title)
|
return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title)
|
||||||
@@ -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
|
// 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.
|
// same successful action rather than an error shown to the viewer.
|
||||||
req.Title = movie.Title
|
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)
|
s.logMediaRequest(r.Context(), req, "already added", nil)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
|
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
|
||||||
return
|
return
|
||||||
@@ -225,6 +271,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Title = added.Title
|
req.Title = added.Title
|
||||||
|
s.recordMediaRequest(r.Context(), sess, req, added.Year,
|
||||||
|
radarrCoverURL(added.Images, "poster"))
|
||||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
||||||
return
|
return
|
||||||
@@ -247,6 +295,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
|||||||
}
|
}
|
||||||
if show.ID > 0 {
|
if show.ID > 0 {
|
||||||
req.Title = show.Title
|
req.Title = show.Title
|
||||||
|
s.recordMediaRequest(r.Context(), sess, req, show.Year,
|
||||||
|
sonarrCoverURL(show.Images, "poster"))
|
||||||
s.logMediaRequest(r.Context(), req, "already added", nil)
|
s.logMediaRequest(r.Context(), req, "already added", nil)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
|
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
|
||||||
return
|
return
|
||||||
@@ -259,6 +309,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Title = added.Title
|
req.Title = added.Title
|
||||||
|
s.recordMediaRequest(r.Context(), sess, req, added.Year,
|
||||||
|
sonarrCoverURL(added.Images, "poster"))
|
||||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
||||||
return
|
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")
|
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(
|
func (s *Server) logMediaRequest(
|
||||||
ctx context.Context, req requestPayload, outcome string, err error,
|
ctx context.Context, req requestPayload, outcome string, err error,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,3 +221,16 @@ func (c *Client) do(req *http.Request, out any) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,6 +180,52 @@ func (s *Store) LibraryContainsProviderIDs(
|
|||||||
return found, rows.Err()
|
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
|
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
|
||||||
// recommendation engine. User state is applied by the caller, which is the only place
|
// recommendation engine. User state is applied by the caller, which is the only place
|
||||||
// that knows it.
|
// that knows it.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user