Big changes
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
name: Publish Memby release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android 35 build tools
|
||||
shell: bash
|
||||
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Read version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITEA_REF_NAME#v}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Tag must look like v0.1.54" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> "$GITEA_ENV"
|
||||
|
||||
- name: Restore release keystore
|
||||
shell: bash
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$GITEA_WORKSPACE/memby-release.jks"
|
||||
|
||||
- name: Test and build signed APK
|
||||
shell: bash
|
||||
env:
|
||||
MEMBY_KEYSTORE: ${{ gitea.workspace }}/memby-release.jks
|
||||
MEMBY_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
MEMBY_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
MEMBY_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --console=plain testDebugUnitTest assembleRelease \
|
||||
-Pmemby.versionName="$VERSION"
|
||||
|
||||
- name: Publish to Memby gateway
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.MEMBY_RELEASE_PUBLISH_TOKEN }}
|
||||
run: |
|
||||
NOTES="$(git log -1 --pretty=%B)"
|
||||
curl --fail-with-body --show-error --silent \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer $RELEASE_TOKEN" \
|
||||
-F "version=$VERSION" \
|
||||
-F "notes=$NOTES" \
|
||||
-F "apk=@app/build/outputs/apk/release/app-release.apk;type=application/vnd.android.package-archive" \
|
||||
https://mserver.sublogue.com/admin/api/release
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.ponzischeme89.memby.data.model.GatewayAlert
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MaintenanceNotice(val message: String)
|
||||
data class CompatibilityNotice(val message: String)
|
||||
|
||||
/**
|
||||
* One informational banner: a show aired, its episode is on its way into Emby. It is
|
||||
* never actionable and never focusable — it slides in, says its piece and goes.
|
||||
*/
|
||||
data class ServiceAlert(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val posterUrl: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One process-wide control channel shared by every Memby activity.
|
||||
*
|
||||
* A network failure leaves the last confirmed state intact: losing connectivity while a
|
||||
* maintenance notice is showing must not briefly reopen playback. A successful status
|
||||
* response is the only thing that enters or clears maintenance.
|
||||
*
|
||||
* The same poll carries alerts, so news reaches an open app without a second connection.
|
||||
* The gateway keeps offering an alert for as long as it is current and has no idea which
|
||||
* TVs have seen it, so *this* side owns "shown already" — persisted, or every relaunch
|
||||
* would replay yesterday's news.
|
||||
*
|
||||
* The loop runs only while a Memby screen is in the foreground, and an alert counts as
|
||||
* shown only when the banner says so ([alertShown]). Both exist for the same reason: work
|
||||
* done for a screen nobody is looking at is worse than wasted, because it also burns the
|
||||
* one chance to deliver the news.
|
||||
*/
|
||||
class MaintenanceMonitor(
|
||||
private val repository: EmbyRepository,
|
||||
private val settings: SettingsStore,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _notice = MutableStateFlow<MaintenanceNotice?>(null)
|
||||
val notice: StateFlow<MaintenanceNotice?> = _notice.asStateFlow()
|
||||
|
||||
private val _compatibility = MutableStateFlow<CompatibilityNotice?>(null)
|
||||
val compatibility: StateFlow<CompatibilityNotice?> = _compatibility.asStateFlow()
|
||||
|
||||
private val _alert = MutableStateFlow<ServiceAlert?>(null)
|
||||
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
|
||||
|
||||
private val seenAlertIds = mutableSetOf<String>()
|
||||
private var seenAlertsLoaded = false
|
||||
private var shownAlertId: String? = null
|
||||
private var alertTimer: Job? = null
|
||||
|
||||
init {
|
||||
scope.launchStatusLoop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the banner once it is actually on screen. Until this arrives the alert is
|
||||
* only *offered*: nothing is persisted and no timer runs, so an alert that lands while
|
||||
* the screensaver is up or another app is in front survives to be shown later rather
|
||||
* than being consumed by nobody.
|
||||
*
|
||||
* Seen is recorded on display rather than on dismissal, because a banner interrupted
|
||||
* by a crash or a power cut is not worth replaying a day later.
|
||||
*/
|
||||
fun alertShown(id: String) {
|
||||
if (shownAlertId == id) return
|
||||
shownAlertId = id
|
||||
seenAlertIds += id
|
||||
scope.launch { runCatching { settings.markAlertSeen(id) } }
|
||||
|
||||
alertTimer?.cancel()
|
||||
alertTimer = scope.launch {
|
||||
delay(ALERT_VISIBLE_MS)
|
||||
if (_alert.value?.id == id) _alert.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides the current banner early — the viewer has already read it. */
|
||||
fun dismissAlert() {
|
||||
alertTimer?.cancel()
|
||||
shownAlertId = null
|
||||
_alert.value = null
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchStatusLoop() = launch {
|
||||
// Only while a Memby screen is in front. Backgrounded, this loop is cancelled
|
||||
// outright rather than left ticking a request every ten seconds at a TV nobody
|
||||
// is looking at; coming back to the foreground restarts it with an immediate
|
||||
// poll, so maintenance is still caught the moment the viewer returns.
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
repository.settingsFlow.collectLatest { session ->
|
||||
if (!ServerConfig.isGateway || !session.isSignedIn) {
|
||||
_notice.value = null
|
||||
_compatibility.value = null
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
while (isActive) {
|
||||
runCatching { repository.serviceStatus() }
|
||||
.onSuccess { status ->
|
||||
_compatibility.value = if (status.compatible) {
|
||||
null
|
||||
} else {
|
||||
CompatibilityNotice(
|
||||
status.compatibilityMessage.trim().ifEmpty {
|
||||
"This Memby app and server are not compatible. Update the app or contact the server administrator."
|
||||
},
|
||||
)
|
||||
}
|
||||
_notice.value = if (status.maintenance) {
|
||||
MaintenanceNotice(
|
||||
status.message.trim().ifEmpty {
|
||||
"Memby is down for maintenance. Try again shortly."
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (status.maintenance) {
|
||||
// The maintenance screen owns the display; anything
|
||||
// cheerful in front of it would only be confusing.
|
||||
dismissAlert()
|
||||
} else {
|
||||
offerNextAlert(status.alerts)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (isUnauthorizedError(error)) {
|
||||
repository.invalidateSession()
|
||||
_notice.value = null
|
||||
_compatibility.value = null
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
}
|
||||
}
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun offerNextAlert(alerts: List<GatewayAlert>) {
|
||||
if (!seenAlertsLoaded) {
|
||||
seenAlertIds += runCatching { settings.seenAlertIds() }.getOrDefault(emptySet())
|
||||
seenAlertsLoaded = true
|
||||
}
|
||||
|
||||
val pending = _alert.value
|
||||
if (pending != null) {
|
||||
if (pendingAlertExpired(pending.id, shownAlertId, alerts)) {
|
||||
_alert.value = null
|
||||
}
|
||||
// One at a time either way; a queued second alert is still unseen next poll.
|
||||
return
|
||||
}
|
||||
|
||||
val next = firstUnseenAlert(alerts, seenAlertIds) ?: return
|
||||
_alert.value = ServiceAlert(
|
||||
id = next.id,
|
||||
title = next.title.trim(),
|
||||
message = next.message.trim(),
|
||||
posterUrl = repository.posterUrl(next.itemId, next.imageTag),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val POLL_INTERVAL_MS = 10_000L
|
||||
|
||||
/**
|
||||
* How long one banner stays on screen. The banner draws a ring counting this
|
||||
* down, so the two must agree — take the duration from here rather than
|
||||
* hard-coding it in the UI.
|
||||
*/
|
||||
const val ALERT_VISIBLE_MS = 10_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an alert held for a screen that never appeared should be given up on.
|
||||
*
|
||||
* An offered alert waits indefinitely for a foreground banner, so something has to end
|
||||
* that wait: once the gateway stops listing it, the episode aired long enough ago that
|
||||
* announcing it is no longer news. An alert already shown is left alone — its own
|
||||
* dismissal timer owns it.
|
||||
*/
|
||||
internal fun pendingAlertExpired(
|
||||
pendingId: String,
|
||||
shownId: String?,
|
||||
alerts: List<GatewayAlert>,
|
||||
): Boolean = pendingId != shownId && alerts.none { it.id == pendingId }
|
||||
|
||||
/**
|
||||
* The first alert this TV has not shown before. Alerts arrive newest-first, and anything
|
||||
* missing an id, a title or a message is dropped rather than rendered as an empty card.
|
||||
*/
|
||||
internal fun firstUnseenAlert(alerts: List<GatewayAlert>, seen: Set<String>): GatewayAlert? =
|
||||
alerts.firstOrNull { alert ->
|
||||
alert.id.isNotBlank() &&
|
||||
alert.id !in seen &&
|
||||
alert.title.isNotBlank() &&
|
||||
alert.message.isNotBlank()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
|
||||
@Serializable
|
||||
data class PlayableSubtitle(
|
||||
val id: String = "",
|
||||
val url: String,
|
||||
val mimeType: String,
|
||||
val language: String? = null,
|
||||
val label: String? = null,
|
||||
val isDefault: Boolean = false,
|
||||
val isForced: Boolean = false,
|
||||
val isHearingImpaired: Boolean = false,
|
||||
val deliveryMethod: String = "External",
|
||||
val codec: String? = null,
|
||||
)
|
||||
|
||||
internal fun subtitleTracks(
|
||||
streams: List<MediaStream>,
|
||||
serverUrl: String,
|
||||
token: String,
|
||||
itemId: String,
|
||||
mediaSourceId: String,
|
||||
): List<PlayableSubtitle> = streams.mapNotNull { stream ->
|
||||
if (!stream.type.equals("Subtitle", true) || stream.index < 0) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val sourceId = mediaSourceId.ifBlank { itemId }
|
||||
val method = stream.deliveryMethod?.takeIf(String::isNotBlank)
|
||||
?: if (stream.isTextSubtitleStream) "External" else "Encode"
|
||||
val mime = subtitleMimeType(stream.codec, stream.deliveryUrl.orEmpty())
|
||||
val extension = subtitleExtension(stream.codec, stream.deliveryUrl.orEmpty())
|
||||
val canonical = "/Videos/${pathSegment(itemId)}/${pathSegment(sourceId)}" +
|
||||
"/Subtitles/${stream.index}/Stream.$extension"
|
||||
val delivery = stream.deliveryUrl?.takeIf(String::isNotBlank) ?: canonical
|
||||
PlayableSubtitle(
|
||||
id = stream.index.toString(),
|
||||
url = if (method.equals("External", true) && mime != null) {
|
||||
authenticatedDeliveryUrl(serverUrl, delivery, token)
|
||||
} else "",
|
||||
mimeType = mime.orEmpty(),
|
||||
language = stream.language?.trim()?.takeIf(String::isNotEmpty),
|
||||
label = (stream.displayTitle ?: stream.title)?.trim()?.takeIf(String::isNotEmpty),
|
||||
isDefault = stream.isDefault,
|
||||
isForced = stream.isForced,
|
||||
isHearingImpaired = stream.isHearingImpaired || listOfNotNull(stream.title, stream.displayTitle).any {
|
||||
it.contains("sdh", true) || it.contains("hearing", true)
|
||||
},
|
||||
deliveryMethod = method,
|
||||
codec = stream.codec,
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
|
||||
private fun subtitleExtension(codec: String?, url: String): String =
|
||||
when (codec?.trim()?.lowercase()) {
|
||||
"subrip" -> "srt"
|
||||
"webvtt" -> "vtt"
|
||||
"tx3g" -> "mov_text"
|
||||
else -> codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
|
||||
?: url.substringBefore('?').substringAfterLast('.', "vtt").lowercase()
|
||||
}
|
||||
|
||||
private fun pathSegment(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
internal fun subtitleMimeType(codec: String?, url: String = ""): String? =
|
||||
when ((codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
|
||||
?: url.substringBefore('?').substringAfterLast('.', "").lowercase())) {
|
||||
"srt", "subrip" -> "application/x-subrip"
|
||||
"vtt", "webvtt" -> "text/vtt"
|
||||
"ass", "ssa" -> "text/x-ssa"
|
||||
"ttml", "dfxp" -> "application/ttml+xml"
|
||||
"tx3g", "mov_text" -> "application/x-quicktime-tx3g"
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun authenticatedDeliveryUrl(serverUrl: String, deliveryUrl: String, token: String): String {
|
||||
val absolute = if (runCatching { URI(deliveryUrl).isAbsolute }.getOrDefault(false)) {
|
||||
deliveryUrl
|
||||
} else {
|
||||
serverUrl.trimEnd('/') + "/" + deliveryUrl.trimStart('/')
|
||||
}
|
||||
if (token.isBlank() || Regex("""(?:[?&])api_key=""", RegexOption.IGNORE_CASE).containsMatchIn(absolute)) {
|
||||
return absolute
|
||||
}
|
||||
val separator = if ('?' in absolute) '&' else '?'
|
||||
return absolute + separator + "api_key=" + URLEncoder.encode(token, "UTF-8")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
|
||||
/**
|
||||
* The one preview shape this app uses: a 1080p TV, landscape, on the launcher's own
|
||||
* near-black. A phone-sized preview would be actively misleading — every layout here is
|
||||
* built for a 10-foot view and D-pad focus.
|
||||
*
|
||||
* Previews render the composable only; they do not run `ServiceLocator`, so anything
|
||||
* previewable has to take its state as parameters rather than reading the repository.
|
||||
* That is worth keeping: it is the same property that makes a composable testable.
|
||||
*/
|
||||
@Preview(
|
||||
name = "TV 1080p",
|
||||
device = Devices.TV_1080p,
|
||||
showBackground = true,
|
||||
backgroundColor = 0xFF090B0D,
|
||||
)
|
||||
annotation class TvPreview
|
||||
|
||||
/** Wraps preview content in the real theme, so type and colours match the running app. */
|
||||
@Composable
|
||||
fun PreviewSurface(
|
||||
alignment: Alignment = Alignment.Center,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MembyTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF090B0D)),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
|
||||
/**
|
||||
* A lifecycle boundary for state that belongs to one signed-in Emby profile.
|
||||
*
|
||||
* The owner is remembered by the profile screen and cleared when that screen leaves
|
||||
* composition. Clearing it cancels the old profile's ViewModel jobs as well as dropping
|
||||
* its rows and caches before another profile is rendered.
|
||||
*/
|
||||
internal class ProfileViewModelStoreOwner : ViewModelStoreOwner {
|
||||
override val viewModelStore = ViewModelStore()
|
||||
|
||||
fun clear() {
|
||||
viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.aspectRatio
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.Color
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.compose.ui.zIndex
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private val SeriesGreen = Color(0xFF52B54B)
|
||||
private val SeriesMuted = Color(0xFFD0D6DB)
|
||||
private val SeriesQuiet = Color(0xFFAEB7BF)
|
||||
|
||||
@Composable
|
||||
fun SeriesDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
|
||||
var loadFailed by remember(item.id) { mutableStateOf(false) }
|
||||
var selectedSectionKey by rememberSaveable(item.id) {
|
||||
mutableStateOf(SeriesDetailSection.EPISODES.key)
|
||||
}
|
||||
val selectedSection = seriesDetailSection(selectedSectionKey)
|
||||
|
||||
LaunchedEffect(item.id) {
|
||||
runCatching { repository.getSeriesEpisodes(item.id) }
|
||||
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
|
||||
.onFailure {
|
||||
loadFailed = true
|
||||
episodes = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
|
||||
var selectedSeason by rememberSaveable(item.id) { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(seasons) {
|
||||
if (selectedSeason !in seasons) {
|
||||
selectedSeason = defaultSeason(episodes.orEmpty())
|
||||
}
|
||||
}
|
||||
val seasonEpisodes = remember(episodes, selectedSeason) {
|
||||
episodesForSeason(episodes.orEmpty(), selectedSeason)
|
||||
}
|
||||
val firstEpisode = remember { FocusRequester() }
|
||||
val seasonFocusRequesters = remember(seasons) {
|
||||
seasons.associateWith { FocusRequester() }
|
||||
}
|
||||
var initialSeasonFocusRequested by remember(item.id) { mutableStateOf(false) }
|
||||
LaunchedEffect(selectedSeason, seasons) {
|
||||
val requester = seasonFocusRequesters[selectedSeason] ?: return@LaunchedEffect
|
||||
if (!initialSeasonFocusRequested) {
|
||||
delay(40)
|
||||
runCatching { requester.requestFocus() }
|
||||
initialSeasonFocusRequested = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF090B0D)),
|
||||
) {
|
||||
BackdropLayer(item, Modifier.fillMaxSize())
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.20f),
|
||||
0.42f to Color.Black.copy(alpha = 0.52f),
|
||||
0.72f to Color(0xF5090B0D),
|
||||
1f to Color(0xFF090B0D),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.72f),
|
||||
0.60f to Color.Black.copy(alpha = 0.18f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 64.dp, end = 52.dp, top = 42.dp, bottom = 34.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth(0.70f)) {
|
||||
Text(
|
||||
"SERIES",
|
||||
color = SeriesGreen,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.4.sp,
|
||||
)
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 40.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val facts = listOfNotNull(
|
||||
item.productionYear?.toString(),
|
||||
seasons.size.takeIf { it > 0 }?.let { count ->
|
||||
"$count ${if (count == 1) "Season" else "Seasons"}"
|
||||
},
|
||||
item.officialRating,
|
||||
item.genres.take(2).joinToString(" · ").takeIf(String::isNotBlank),
|
||||
)
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(" • "),
|
||||
color = SeriesMuted,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { onToggleFavorite(item, !item.isFavorite) }) {
|
||||
Text(if (item.isFavorite) "Remove favourite" else "Add favourite")
|
||||
}
|
||||
Button(onClick = onClose) { Text("Close") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(15.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SeriesDetailTabs(
|
||||
selected = selectedSection,
|
||||
episodeCount = episodes?.size,
|
||||
castCount = item.cast.size,
|
||||
onSelected = { selectedSectionKey = it.key },
|
||||
)
|
||||
if (selectedSection == SeriesDetailSection.EPISODES && seasons.isNotEmpty()) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.width(1.dp)
|
||||
.height(30.dp)
|
||||
.background(Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
items(seasons, key = { it }) { season ->
|
||||
SeasonChip(
|
||||
season = season,
|
||||
selected = season == selectedSeason,
|
||||
focusRequester = seasonFocusRequesters.getValue(season),
|
||||
episodeFocusRequester = firstEpisode,
|
||||
onClick = { selectedSeason = season },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
when {
|
||||
selectedSection == SeriesDetailSection.CAST -> {
|
||||
if (item.cast.isEmpty()) {
|
||||
Text(
|
||||
"Cast information is not available for this show.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
} else {
|
||||
CastRail(
|
||||
people = item.cast,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
showTitle = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
episodes == null -> {
|
||||
Text(
|
||||
"Loading seasons…",
|
||||
color = SeriesQuiet,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
loadFailed -> {
|
||||
Text(
|
||||
"Episodes are temporarily unavailable.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
seasons.isEmpty() -> {
|
||||
Text(
|
||||
"No episodes are available for this show.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
start = 2.dp,
|
||||
end = 42.dp,
|
||||
top = 7.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
items(seasonEpisodes, key = BaseItem::id) { episode ->
|
||||
EpisodeCard(
|
||||
episode = episode,
|
||||
onClick = { onPlay(episode) },
|
||||
seasonFocusRequester = seasonFocusRequesters[selectedSeason]
|
||||
?: FocusRequester.Default,
|
||||
modifier = if (episode.id == seasonEpisodes.first().id) {
|
||||
Modifier
|
||||
.focusRequester(firstEpisode)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class SeriesDetailSection(val key: String) {
|
||||
EPISODES("episodes"),
|
||||
CAST("cast"),
|
||||
}
|
||||
|
||||
internal fun seriesDetailSection(key: String): SeriesDetailSection =
|
||||
SeriesDetailSection.entries.firstOrNull { it.key == key } ?: SeriesDetailSection.EPISODES
|
||||
|
||||
@Composable
|
||||
private fun SeriesDetailTabs(
|
||||
selected: SeriesDetailSection,
|
||||
episodeCount: Int?,
|
||||
castCount: Int,
|
||||
onSelected: (SeriesDetailSection) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SeriesDetailTab(
|
||||
label = buildString {
|
||||
append("Episodes")
|
||||
episodeCount?.takeIf { it > 0 }?.let { append(" $it") }
|
||||
},
|
||||
selected = selected == SeriesDetailSection.EPISODES,
|
||||
onClick = { onSelected(SeriesDetailSection.EPISODES) },
|
||||
)
|
||||
SeriesDetailTab(
|
||||
label = buildString {
|
||||
append("Cast")
|
||||
castCount.takeIf { it > 0 }?.let { append(" $it") }
|
||||
},
|
||||
selected = selected == SeriesDetailSection.CAST,
|
||||
onClick = { onSelected(SeriesDetailSection.CAST) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesDetailTab(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
|
||||
val background = when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen.copy(alpha = 0.18f)
|
||||
else -> Color.White.copy(alpha = 0.055f)
|
||||
}
|
||||
val textColor = if (focused) Color.Black else if (selected) Color.White else SeriesQuiet
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(background)
|
||||
.border(
|
||||
1.dp,
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen.copy(alpha = 0.72f)
|
||||
else -> Color.White.copy(alpha = 0.10f)
|
||||
},
|
||||
shape,
|
||||
)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 21.dp, top = 10.dp, end = 21.dp, bottom = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = textColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 7.dp)
|
||||
.width(34.dp)
|
||||
.height(3.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(2.dp))
|
||||
.background(if (selected && !focused) SeriesGreen else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonChip(
|
||||
season: Int,
|
||||
selected: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
episodeFocusRequester: FocusRequester,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(7.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen
|
||||
else -> Color(0xCC20252A)
|
||||
},
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = if (selected) 0.28f else 0.12f), shape)
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties { down = episodeFocusRequester }
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 17.dp, vertical = 9.dp),
|
||||
) {
|
||||
Text(
|
||||
if (season == 0) "Specials" else "Season $season",
|
||||
color = if (focused) Color.Black else Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(
|
||||
episode: BaseItem,
|
||||
onClick: () -> Unit,
|
||||
seasonFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val imageUrl = remember(episode.id) {
|
||||
repository.primaryUrl(episode, 360) ?: repository.backdropUrl(episode, 360)
|
||||
}
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
val progress = remember(episode.userData, episode.runTimeTicks) {
|
||||
val runtime = episode.runTimeTicks ?: 0L
|
||||
if (runtime > 0L) {
|
||||
((episode.userData?.playbackPositionTicks ?: 0L).toFloat() / runtime).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (focused) 1.045f else 1f,
|
||||
animationSpec = tween(110),
|
||||
label = "episode-card-focus",
|
||||
)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(252.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.zIndex(if (focused) 1f else 0f)
|
||||
.focusProperties { up = seasonFocusRequester }
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(bottom = 4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(shape)
|
||||
.background(Color(0xFF20252A)),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.border(
|
||||
width = if (focused) 3.dp else 1.dp,
|
||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.10f),
|
||||
shape = shape,
|
||||
),
|
||||
)
|
||||
if (progress > 0f) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(Color.Black.copy(alpha = 0.65f)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.height(4.dp)
|
||||
.background(SeriesGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (episode.userData?.played == true) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Watched",
|
||||
tint = SeriesGreen,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(9.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 9.dp),
|
||||
) {
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
episode.indexNumber?.let { "$it." },
|
||||
episode.name,
|
||||
).joinToString(" "),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
episode.runtimeMinutes?.let {
|
||||
Text(
|
||||
"${it}m",
|
||||
color = SeriesQuiet,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
episode.overview?.takeIf(String::isNotBlank) ?: "No episode description available.",
|
||||
color = if (focused) SeriesMuted else SeriesQuiet.copy(alpha = 0.72f),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val seriesEpisodeComparator =
|
||||
compareBy<BaseItem>({ it.parentIndexNumber ?: Int.MAX_VALUE }, { it.indexNumber ?: Int.MAX_VALUE }, { it.name })
|
||||
|
||||
internal fun availableSeasons(episodes: List<BaseItem>): List<Int> =
|
||||
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
|
||||
|
||||
internal fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
|
||||
if (season == null) emptyList()
|
||||
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
||||
|
||||
internal fun defaultSeason(episodes: List<BaseItem>): Int? =
|
||||
episodes.firstOrNull {
|
||||
it.userData?.played != true || ((it.userData?.playbackPositionTicks ?: 0L) > 0L)
|
||||
}?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
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.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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
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.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
import kotlin.math.ceil
|
||||
|
||||
private val AlertAccent = Color(0xFF52B54B)
|
||||
private val AlertTitle = Color(0xFFF2F5F7)
|
||||
private val AlertBody = Color(0xFFC3CBD2)
|
||||
|
||||
/** Height of the bar itself, before the rule and fade that blend it into the screen. */
|
||||
private val BannerHeight = 104.dp
|
||||
|
||||
/**
|
||||
* Broadcast-style overscan inset. TVs crop the edges of the picture by a few percent, so
|
||||
* nothing meaningful may sit closer than this to the frame.
|
||||
*/
|
||||
private val SafeAreaHorizontal = 48.dp
|
||||
|
||||
/**
|
||||
* A full-width bar that drops in over the top of the screen to say an episode has aired —
|
||||
* the shape a broadcaster uses for an in-programme notice, rather than a corner toast.
|
||||
*
|
||||
* Deliberately not focusable and not dismissable by D-pad: it must never steal focus from
|
||||
* a row mid-browse, so it times itself out instead of asking the viewer to act. It spans
|
||||
* the navigation rail as well, because for its few seconds it is the top layer of the
|
||||
* screen; it draws above the rows but below the update prompt, which is the one thing
|
||||
* allowed to own the whole display.
|
||||
*/
|
||||
@Composable
|
||||
fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
|
||||
// The alert is collected *here* rather than passed down from the home composable.
|
||||
// Read one scope up and every arriving alert would recompose the entire launcher
|
||||
// body; read here and it recomposes a bar that is usually not even on screen.
|
||||
val current by ServiceLocator.maintenance.alert.collectAsStateWithLifecycle()
|
||||
val alert = current?.takeUnless { suppressed }
|
||||
|
||||
// Tell the monitor the moment this is really on screen. Until it hears that, the
|
||||
// alert is only offered — it starts no dismissal timer and persists nothing — so an
|
||||
// alert arriving behind the screensaver or another app is not silently used up.
|
||||
LaunchedEffect(alert?.id) {
|
||||
alert?.id?.let { ServiceLocator.maintenance.alertShown(it) }
|
||||
}
|
||||
|
||||
// The exit animation still needs something to draw, so hold the last alert until the
|
||||
// slide-out has finished with it.
|
||||
var lastAlert by remember { mutableStateOf<ServiceAlert?>(null) }
|
||||
if (alert != null) lastAlert = alert
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = alert != null,
|
||||
// In from above the frame, out the same way. Slower arriving than leaving:
|
||||
// showing up should be noticed, going away should not.
|
||||
enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeIn(tween(260)),
|
||||
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeOut(tween(220)),
|
||||
modifier = modifier.zIndex(8f),
|
||||
) {
|
||||
lastAlert?.let { AlertBanner(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Internal so the screenshot test can render the bar on its own, without the drop-in
|
||||
// wrapper around it.
|
||||
@Composable
|
||||
internal fun AlertBanner(
|
||||
alert: ServiceAlert,
|
||||
visibleMillis: Long = MaintenanceMonitor.ALERT_VISIBLE_MS,
|
||||
) {
|
||||
// Keyed on the alert id so a second banner arriving restarts the countdown rather
|
||||
// than inheriting whatever was left of the first one's.
|
||||
val remaining = remember(alert.id) { Animatable(1f) }
|
||||
LaunchedEffect(alert.id) {
|
||||
remaining.animateTo(0f, tween(visibleMillis.toInt(), easing = LinearEasing))
|
||||
}
|
||||
// The ring's fraction is handed down as a lambda and the seconds as derived state, so
|
||||
// ten seconds of animation costs ~10 recompositions of one number instead of ~600 of
|
||||
// this whole bar. On a weak TV box that difference is the feature's entire cost.
|
||||
val secondsLeft = remember(alert.id, visibleMillis) {
|
||||
derivedStateOf { ceil(remaining.value * visibleMillis / 1000f).toInt() }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(BannerHeight)
|
||||
.background(
|
||||
// Darkest at the left where the text sits, easing off to the right so
|
||||
// the artwork behind the banner still shows through.
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xFF0E1418),
|
||||
0.55f to Color(0xF20E1418),
|
||||
1f to Color(0xD9121A20),
|
||||
),
|
||||
)
|
||||
.padding(horizontal = SafeAreaHorizontal),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AlertPoster(posterUrl = alert.posterUrl)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
LivePip()
|
||||
Text(
|
||||
"JUST AIRED",
|
||||
color = AlertAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.6.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(3.dp))
|
||||
// Keep the alert readable at TV distance without letting it overpower
|
||||
// the screen content beneath it.
|
||||
Text(
|
||||
alert.title,
|
||||
color = AlertTitle,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
alert.message,
|
||||
color = AlertBody,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
|
||||
}
|
||||
|
||||
// An accent rule under the bar, then a short fade: without them the banner ends
|
||||
// on a hard line across the artwork, which reads as a rendering seam.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(Color(0x99000000), Color.Transparent)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring that empties as the banner's time runs out, with the seconds left inside it.
|
||||
*
|
||||
* This exists to answer "is it about to go, or did it stick?" — the banner cannot be
|
||||
* dismissed by remote, so the one thing the viewer can usefully know is how long they
|
||||
* have to read it. Drawn in a single Canvas: an arc and a track are cheaper than any
|
||||
* progress component, and this animates every frame for ten seconds.
|
||||
*
|
||||
* [fraction] is a lambda and [secondsLeft] a [State] on purpose. Read either one in a
|
||||
* composable body and every frame recomposes; read them inside the draw lambda and in a
|
||||
* derived state, and the sweep costs a redraw only.
|
||||
*/
|
||||
@Composable
|
||||
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val stroke = 3.dp.toPx()
|
||||
val inset = stroke / 2f
|
||||
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||
drawArc(
|
||||
color = Color.White.copy(alpha = 0.12f),
|
||||
startAngle = 0f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(inset, inset),
|
||||
size = arcSize,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
)
|
||||
drawArc(
|
||||
color = AlertAccent,
|
||||
// Twelve o'clock, unwinding clockwise: the direction a clock hand sweeps,
|
||||
// so "less arc left" reads as "less time left" without a legend.
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f * fraction().coerceIn(0f, 1f),
|
||||
useCenter = false,
|
||||
topLeft = Offset(inset, inset),
|
||||
size = arcSize,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
secondsLeft.value.coerceAtLeast(0).toString(),
|
||||
color = AlertBody,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Poster when the server named one; a quiet accent tile when it did not. */
|
||||
@Composable
|
||||
private fun AlertPoster(posterUrl: String?) {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(50.dp)
|
||||
.height(74.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0xFF18222B)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (posterUrl != null) {
|
||||
AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(AlertAccent.copy(alpha = 0.30f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A slow pulse — enough motion to read as "news", cheap enough for a TV GPU. */
|
||||
@Composable
|
||||
private fun LivePip() {
|
||||
val alpha = androidx.compose.animation.core.rememberInfiniteTransition(label = "alert-pip")
|
||||
.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
tween(1_400, easing = LinearEasing),
|
||||
RepeatMode.Reverse,
|
||||
),
|
||||
label = "alert-pip-alpha",
|
||||
)
|
||||
// Deliberately not `by`: the alpha is read inside the draw lambda, so the pulse
|
||||
// repaints eight dp of circle rather than recomposing the row that holds it.
|
||||
Canvas(Modifier.size(8.dp)) {
|
||||
drawCircle(color = AlertAccent.copy(alpha = alpha.value))
|
||||
}
|
||||
}
|
||||
|
||||
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
|
||||
// whole job is the drop-in, and a still frame of an animation in progress says nothing.
|
||||
// Posters are left null on purpose — the preview has no network, so this is also the
|
||||
// fallback tile being checked.
|
||||
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerPreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Long strings are common; the single-line ellipsis is the part worth eyeballing. */
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerLongTitlePreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "sonarr:9:88:aired",
|
||||
title = "A Very Long Programme Title That Will Not Fit On One Line At All",
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"aired at 10:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.ponzischeme89.memby.ui.detail
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Everything the detail pages need to *say*, as pure functions.
|
||||
*
|
||||
* None of this touches Compose, the repository or Android, so the whole vocabulary of the
|
||||
* two screens — which season opens first, what the primary button is called, which
|
||||
* technical facts are worth a line — is unit-testable without a device. The composables in
|
||||
* this package are deliberately thin over these.
|
||||
*/
|
||||
|
||||
/** Emby stores durations and positions as 100-ns ticks. */
|
||||
private const val TICKS_PER_MINUTE = 600_000_000L
|
||||
|
||||
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
|
||||
fun formatRuntime(minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
val remainder = minutes % 60
|
||||
return when {
|
||||
hours > 0 && remainder > 0 -> "${hours}h ${remainder}m"
|
||||
hours > 0 -> "${hours}h"
|
||||
else -> "${remainder}m"
|
||||
}
|
||||
}
|
||||
|
||||
/** Position on the playhead, in the same wording as a runtime. */
|
||||
fun formatPosition(ticks: Long): String =
|
||||
formatRuntime((ticks / TICKS_PER_MINUTE).toInt().coerceAtLeast(0))
|
||||
|
||||
val BaseItem.resumeTicks: Long get() = userData?.playbackPositionTicks ?: 0L
|
||||
|
||||
val BaseItem.isPlayed: Boolean get() = userData?.played == true
|
||||
|
||||
/** True when there is a meaningful position to resume from. */
|
||||
val BaseItem.isResumable: Boolean get() = resumeTicks > 0L
|
||||
|
||||
/** 0f..1f through the item, or 0f when either end is unknown. */
|
||||
fun playbackProgress(item: BaseItem): Float {
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
if (runtime <= 0L) return 0f
|
||||
return (item.resumeTicks.toFloat() / runtime).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** "1h 12m left", or null when the remainder cannot be worked out. */
|
||||
fun remainingLabel(item: BaseItem): String? {
|
||||
val runtime = item.runTimeTicks ?: return null
|
||||
val left = runtime - item.resumeTicks
|
||||
if (item.resumeTicks <= 0L || left <= TICKS_PER_MINUTE) return null
|
||||
return "${formatPosition(left)} left"
|
||||
}
|
||||
|
||||
/** The headline row under a movie title: year, runtime, certificate, score, genres. */
|
||||
fun movieFacts(item: BaseItem): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
|
||||
/** The same row for a series, where seasons replace runtime. */
|
||||
fun seriesFacts(item: BaseItem, seasonCount: Int): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
if (seasonCount > 0) add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
|
||||
/** A label/value pair for the quiet technical area. */
|
||||
data class TechnicalSpec(val label: String, val value: String)
|
||||
|
||||
/**
|
||||
* Resolution, codecs and studio — the things a viewer checks before settling in, kept out
|
||||
* of the headline because none of them decide what to watch.
|
||||
*/
|
||||
fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
val video = item.mediaStreams.firstOrNull { it.type.equals("Video", ignoreCase = true) }
|
||||
val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", ignoreCase = true) }
|
||||
val subtitles = item.mediaStreams.count { it.type.equals("Subtitle", ignoreCase = true) }
|
||||
return buildList {
|
||||
video?.let { stream ->
|
||||
val width = stream.width
|
||||
val height = stream.height
|
||||
if (width != null && height != null) {
|
||||
add(TechnicalSpec("Video", "$width × $height${resolutionSuffix(width)}"))
|
||||
}
|
||||
listOfNotNull(
|
||||
stream.codec?.uppercase(Locale.US),
|
||||
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Codec", it.joinToString(" · ")))
|
||||
}
|
||||
}
|
||||
audio?.let { stream ->
|
||||
listOfNotNull(
|
||||
stream.codec?.uppercase(Locale.US),
|
||||
stream.channels?.let(::channelLabel),
|
||||
stream.language?.takeIf(String::isNotBlank),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Audio", it.joinToString(" · ")))
|
||||
}
|
||||
}
|
||||
if (subtitles > 0) {
|
||||
add(TechnicalSpec("Subtitles", "$subtitles ${if (subtitles == 1) "track" else "tracks"}"))
|
||||
}
|
||||
item.studios.map { it.name }.filter(String::isNotBlank).take(2)
|
||||
.takeIf(List<String>::isNotEmpty)
|
||||
?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolutionSuffix(width: Int): String = when {
|
||||
width >= 3_400 -> " (4K)"
|
||||
width >= 2_500 -> " (1440p)"
|
||||
width >= 1_800 -> " (1080p)"
|
||||
width >= 1_200 -> " (720p)"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
|
||||
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
|
||||
return when {
|
||||
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
|
||||
"hdr10+" in haystack -> "HDR10+"
|
||||
"hdr" in haystack -> "HDR"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelLabel(channels: Int): String = when (channels) {
|
||||
1 -> "Mono"
|
||||
2 -> "Stereo"
|
||||
6 -> "5.1"
|
||||
8 -> "7.1"
|
||||
else -> "${channels}ch"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Running order: season, then episode, then title for anything unnumbered. */
|
||||
val seriesEpisodeComparator = compareBy<BaseItem>(
|
||||
{ it.parentIndexNumber ?: Int.MAX_VALUE },
|
||||
{ it.indexNumber ?: Int.MAX_VALUE },
|
||||
{ it.name },
|
||||
)
|
||||
|
||||
/** Season numbers present in the library, specials (0) first. */
|
||||
fun availableSeasons(episodes: List<BaseItem>): List<Int> =
|
||||
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
|
||||
|
||||
fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
|
||||
if (season == null) emptyList()
|
||||
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
||||
|
||||
/**
|
||||
* What the viewer should watch next: whatever is part-watched, else the first unwatched
|
||||
* episode. Specials are skipped while any numbered season exists — season 0 sorts first
|
||||
* but is almost never where someone is up to.
|
||||
*/
|
||||
fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
|
||||
if (episodes.isEmpty()) return null
|
||||
val ordered = episodes.sortedWith(seriesEpisodeComparator)
|
||||
val hasNumberedSeason = ordered.any { (it.parentIndexNumber ?: 0) > 0 }
|
||||
val candidates = if (hasNumberedSeason) {
|
||||
ordered.filter { (it.parentIndexNumber ?: 0) > 0 }
|
||||
} else {
|
||||
ordered
|
||||
}
|
||||
return candidates.firstOrNull { it.isResumable && !it.isPlayed }
|
||||
?: candidates.firstOrNull { !it.isPlayed }
|
||||
}
|
||||
|
||||
/** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */
|
||||
fun defaultSeason(episodes: List<BaseItem>): Int? =
|
||||
nextEpisodeToWatch(episodes)?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
|
||||
|
||||
/** How many episodes are still unwatched, for the series badge. */
|
||||
fun unwatchedCount(episodes: List<BaseItem>): Int = episodes.count { !it.isPlayed }
|
||||
|
||||
/** "Season 3", or "Specials" for season 0. */
|
||||
fun seasonLabel(season: Int): String = if (season == 0) "Specials" else "Season $season"
|
||||
|
||||
/** "S2 E4" — shorter than [BaseItem.episodeCode] and used where space is tight. */
|
||||
fun episodeLabel(episode: BaseItem): String? {
|
||||
val number = episode.indexNumber ?: return null
|
||||
val season = episode.parentIndexNumber
|
||||
return if (season != null) "S$season E$number" else "E$number"
|
||||
}
|
||||
|
||||
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
|
||||
fun episodeHeadline(episode: BaseItem): String =
|
||||
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
|
||||
.joinToString(" · ")
|
||||
|
||||
/**
|
||||
* The primary button. Series pass their next episode so the button can name it; a movie
|
||||
* passes itself.
|
||||
*/
|
||||
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String = when {
|
||||
nextEpisode != null && nextEpisode.isResumable -> "Resume ${episodeLabel(nextEpisode) ?: "episode"}"
|
||||
nextEpisode != null -> "Play ${episodeLabel(nextEpisode) ?: "next episode"}"
|
||||
item.isResumable -> "Resume"
|
||||
else -> "Play"
|
||||
}
|
||||
|
||||
/** The supporting line under the primary button, or null when there is nothing to add. */
|
||||
fun primaryActionDetail(item: BaseItem, nextEpisode: BaseItem? = null): String? {
|
||||
val target = nextEpisode ?: item
|
||||
return when {
|
||||
target.isResumable -> "From ${formatPosition(target.resumeTicks)}" +
|
||||
(remainingLabel(target)?.let { " · $it" } ?: "")
|
||||
nextEpisode != null -> nextEpisode.name.takeIf(String::isNotBlank)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Kicker above the title: what kind of thing this page is about. */
|
||||
fun detailKicker(item: BaseItem): String = when {
|
||||
item.isMovie -> "MOVIE"
|
||||
item.isSeries -> "SERIES"
|
||||
item.isEpisode -> item.seriesName?.uppercase(Locale.US) ?: "EPISODE"
|
||||
else -> item.type.uppercase(Locale.US).ifBlank { "LIBRARY" }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
|
||||
/**
|
||||
* A viewer-facing description of a Media3 failure. Keep this independent from Activity
|
||||
* state so the retry decision remains deterministic and unit-testable.
|
||||
*/
|
||||
internal data class PlaybackFailure(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val canAutoRetry: Boolean,
|
||||
)
|
||||
|
||||
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
||||
when (errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
-> PlaybackFailure(
|
||||
title = "Connection interrupted",
|
||||
detail = "Memby couldn’t keep a reliable connection to the media server.",
|
||||
canAutoRetry = true,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
||||
-> PlaybackFailure(
|
||||
title = "Video unavailable",
|
||||
detail = "The media server couldn’t provide this video. It may have moved or be temporarily unavailable.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
|
||||
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||
-> PlaybackFailure(
|
||||
title = "Playback blocked",
|
||||
detail = "The TV is not permitted to open this stream. Check the server address and access settings.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
|
||||
-> PlaybackFailure(
|
||||
title = "Video format not supported",
|
||||
detail = "This TV couldn’t decode the selected video or audio track. Try another track or a transcoded version.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
||||
-> PlaybackFailure(
|
||||
title = "Video file couldn’t be read",
|
||||
detail = "The stream format is damaged or unsupported by this TV.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
else -> PlaybackFailure(
|
||||
title = "Playback stopped",
|
||||
detail = "Memby hit an unexpected playback problem.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun automaticRetryDelayMs(attempt: Int): Long? =
|
||||
when (attempt) {
|
||||
1 -> 1_000L
|
||||
2 -> 3_000L
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.PlaybackSession
|
||||
|
||||
/**
|
||||
* A persisted final check-in. WorkManager keeps this request across process death, so
|
||||
* Back, task removal and background process eviction all converge on the same Emby stop.
|
||||
*/
|
||||
class PlaybackStopWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
ServiceLocator.init(applicationContext)
|
||||
val itemId = inputData.getString(ITEM_ID).orEmpty()
|
||||
if (itemId.isBlank()) return Result.failure()
|
||||
val session = PlaybackSession(
|
||||
itemId = itemId,
|
||||
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
|
||||
playSessionId = inputData.getString(PLAY_SESSION_ID).orEmpty(),
|
||||
playMethod = inputData.getString(PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" },
|
||||
)
|
||||
return runCatching {
|
||||
ServiceLocator.repository.reportPlaybackStopped(
|
||||
session,
|
||||
inputData.getLong(POSITION_MS, 0L),
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = { if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure() },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ITEM_ID = "item_id"
|
||||
private const val MEDIA_SOURCE_ID = "media_source_id"
|
||||
private const val PLAY_SESSION_ID = "play_session_id"
|
||||
private const val PLAY_METHOD = "play_method"
|
||||
private const val POSITION_MS = "position_ms"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) {
|
||||
val data = Data.Builder()
|
||||
.putString(ITEM_ID, session.itemId)
|
||||
.putString(MEDIA_SOURCE_ID, session.mediaSourceId)
|
||||
.putString(PLAY_SESSION_ID, session.playSessionId)
|
||||
.putString(PLAY_METHOD, session.playMethod)
|
||||
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
|
||||
.setInputData(data)
|
||||
.build()
|
||||
val key = session.playSessionId.ifBlank { session.itemId }
|
||||
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
|
||||
"emby-playback-stop-$key",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
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.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.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.LiveTv
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||
import androidx.compose.material.icons.filled.SentimentVerySatisfied
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SpaceBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.key.utf16CodePoint
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||
|
||||
private val PaneBackground = Color(0xFF0C1014)
|
||||
private val KeyIdle = Color(0xFF1A2129)
|
||||
private val KeyFocused = Color(0xFF52B54B)
|
||||
private val KeyLabel = Color(0xFFE8EDF1)
|
||||
private val KeyLabelFocused = Color(0xFF06240A)
|
||||
private val Heading = Color(0xFFF2F5F7)
|
||||
private val Muted = Color(0xFFB7C0C8)
|
||||
private val Accent = Color(0xFF52B54B)
|
||||
|
||||
/**
|
||||
* Six columns of six, then a row of actions. Rectangular on purpose: every key has a
|
||||
* neighbour directly above and below it, so D-pad movement is predictable in a way a
|
||||
* QWERTY layout with ragged rows never is.
|
||||
*/
|
||||
private val KeyboardRows = listOf(
|
||||
"ABCDEF",
|
||||
"GHIJKL",
|
||||
"MNOPQR",
|
||||
"STUVWX",
|
||||
"YZ0123",
|
||||
"456789",
|
||||
)
|
||||
|
||||
/** Left pane share of the width. Wide enough for six comfortable keys at TV distance. */
|
||||
private const val KEYBOARD_PANE_FRACTION = 0.35f
|
||||
|
||||
/** Posters prefetched as soon as results land, so the first visible row is never blank. */
|
||||
private const val PREFETCHED_POSTERS = 8
|
||||
|
||||
/**
|
||||
* Full-screen search: keyboard on the left, results on the right, updating as you type.
|
||||
*
|
||||
* The two panes are the whole point — the results never disappear behind a keyboard, and
|
||||
* no query is ever "submitted". Everything below is in service of that: focus that moves
|
||||
* predictably between the panes, and a result pane that updates without flashing.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
discoveryItems: List<BaseItem>,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
initialQuery: String? = null,
|
||||
onInitialQueryConsumed: () -> Unit = {},
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onContentFocused: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
val viewModel: SearchViewModel = viewModel(
|
||||
factory = remember(repository) { SearchViewModelFactory(repository) },
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) }
|
||||
LaunchedEffect(initialQuery) {
|
||||
initialQuery?.takeIf { it.isNotBlank() }?.let {
|
||||
viewModel.onQueryChanged(it)
|
||||
onInitialQueryConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
val resultsEntry = remember { FocusRequester() }
|
||||
// The rail and the screen agree on one entry target: Search opens on the keyboard,
|
||||
// just as browse destinations open on their primary content action.
|
||||
val keyboardEntry = contentFocusRequester
|
||||
// Where "left out of the results" lands. It follows the last key used, so coming back
|
||||
// returns the viewer to where they were typing rather than to a fixed corner.
|
||||
val keyboardReturn = remember { FocusRequester() }
|
||||
var lastKeyIndex by remember { mutableIntStateOf(0) }
|
||||
var focusInResults by remember { mutableStateOf(false) }
|
||||
val hasResultsTarget = when {
|
||||
state.errorMessage != null && state.results.isEmpty() -> true
|
||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
else -> state.results.isNotEmpty()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
// Results → keyboard → clear → leave. Each press does one obvious thing, and
|
||||
// none of them can loop back to the previous state.
|
||||
focusInResults -> runCatching { keyboardReturn.requestFocus() }
|
||||
.onFailure { runCatching { keyboardEntry.requestFocus() } }
|
||||
state.query.isNotEmpty() -> viewModel.clearQuery()
|
||||
else -> onExit()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// A USB keyboard, or a phone remote app sending key events, feeds exactly the
|
||||
// same query state as the on-screen keys. Only printable characters and
|
||||
// backspace are consumed; D-pad and Back must fall through untouched.
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val code = event.utf16CodePoint
|
||||
when {
|
||||
code == BACKSPACE_CODE -> {
|
||||
viewModel.backspace(); true
|
||||
}
|
||||
code >= FIRST_PRINTABLE_CODE -> {
|
||||
viewModel.appendToQuery(code.toChar().toString()); true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
) {
|
||||
SearchPane(
|
||||
state = state,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardEntry = keyboardEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
lastKeyIndex = lastKeyIndex,
|
||||
hasResultsTarget = hasResultsTarget,
|
||||
onKeyFocused = { index ->
|
||||
lastKeyIndex = index
|
||||
focusInResults = false
|
||||
onContentFocused()
|
||||
},
|
||||
onCharacter = viewModel::appendToQuery,
|
||||
onBackspace = viewModel::backspace,
|
||||
onClear = viewModel::clearQuery,
|
||||
onVoiceResult = viewModel::onQueryChanged,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(KEYBOARD_PANE_FRACTION)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
ResultsPane(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
returnFocusItemId = returnFocusItemId,
|
||||
returnFocusRequester = returnFocusRequester,
|
||||
discoveryItems = discoveryItems,
|
||||
onItemFocused = { item ->
|
||||
focusInResults = true
|
||||
onContentFocused()
|
||||
onItemFocused(item)
|
||||
},
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onSuggestionSelected = viewModel::onQueryChanged,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchPane(
|
||||
state: SearchUiState,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
lastKeyIndex: Int,
|
||||
hasResultsTarget: Boolean,
|
||||
onKeyFocused: (Int) -> Unit,
|
||||
onCharacter: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onVoiceResult: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(PaneBackground)
|
||||
.padding(start = 28.dp, end = 20.dp, top = 24.dp, bottom = 16.dp),
|
||||
) {
|
||||
Text("Search", color = Heading, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
QueryField(
|
||||
query = state.query,
|
||||
loading = state.isLoading,
|
||||
onVoiceResult = onVoiceResult,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
TvKeyboard(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardEntry = keyboardEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
lastKeyIndex = lastKeyIndex,
|
||||
hasResultsTarget = hasResultsTarget,
|
||||
onKeyFocused = onKeyFocused,
|
||||
onCharacter = onCharacter,
|
||||
onBackspace = onBackspace,
|
||||
onClear = onClear,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The query as typed, plus the two things that belong beside it: a quiet progress dot
|
||||
* while a search is in flight, and voice input when the device offers it.
|
||||
*/
|
||||
@Composable
|
||||
private fun QueryField(
|
||||
query: String,
|
||||
loading: Boolean,
|
||||
onVoiceResult: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val voiceAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) }
|
||||
val voiceLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val spoken = result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
?.firstOrNull()
|
||||
?.trim()
|
||||
// Replaces rather than appends: dictating a title after typing half of another
|
||||
// one almost always means "no, this instead".
|
||||
if (!spoken.isNullOrEmpty()) onVoiceResult(spoken)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF151C23))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = if (query.isEmpty()) Muted else Accent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = query.ifEmpty { "Search movies, shows and episodes" },
|
||||
color = if (query.isEmpty()) Muted else Heading,
|
||||
// Fixed size rather than shrinking as the query grows: readable at three
|
||||
// metres matters more than fitting a long query on one line.
|
||||
fontSize = if (query.isEmpty()) 15.sp else 17.sp,
|
||||
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (loading) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
LoadingDot()
|
||||
}
|
||||
if (voiceAvailable) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = {
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
||||
)
|
||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title")
|
||||
}
|
||||
// A device can advertise recognition and still have nothing to
|
||||
// launch; failing silently beats crashing the search screen.
|
||||
runCatching { voiceLauncher.launch(intent) }
|
||||
},
|
||||
contentDescription = "Search by voice",
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
) { focused ->
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = if (focused) KeyLabelFocused else KeyLabel,
|
||||
modifier = Modifier
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(6.dp)
|
||||
.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvKeyboard(
|
||||
navigationFocusRequester: FocusRequester,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
lastKeyIndex: Int,
|
||||
hasResultsTarget: Boolean,
|
||||
onKeyFocused: (Int) -> Unit,
|
||||
onCharacter: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
KeyboardRows.forEachIndexed { rowIndex, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
row.forEachIndexed { columnIndex, character ->
|
||||
val index = rowIndex * KEYBOARD_COLUMNS + columnIndex
|
||||
KeyboardKey(
|
||||
label = character.toString(),
|
||||
contentDescription = "Type ${character}",
|
||||
onClick = { onCharacter(character.toString()) },
|
||||
onFocused = { onKeyFocused(index) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// Explicit edges. Left of the first column is the navigation
|
||||
// rail, right of the last is the results grid: focus can
|
||||
// always get out of this pane, and never falls off it.
|
||||
.focusProperties {
|
||||
if (columnIndex == 0) left = navigationFocusRequester
|
||||
if (columnIndex == KEYBOARD_COLUMNS - 1) {
|
||||
right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (index == 0) Modifier.focusRequester(keyboardEntry) else Modifier,
|
||||
)
|
||||
.then(
|
||||
if (index == lastKeyIndex) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ActionKey(
|
||||
icon = Icons.Default.SpaceBar,
|
||||
label = "Space",
|
||||
contentDescription = "Insert a space",
|
||||
onClick = { onCharacter(" ") },
|
||||
onFocused = { onKeyFocused(ACTION_ROW_INDEX) },
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.then(
|
||||
if (lastKeyIndex == ACTION_ROW_INDEX) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
ActionKey(
|
||||
icon = Icons.AutoMirrored.Filled.Backspace,
|
||||
label = "Delete",
|
||||
contentDescription = "Delete the last character",
|
||||
onClick = onBackspace,
|
||||
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 1) },
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.then(
|
||||
if (lastKeyIndex == ACTION_ROW_INDEX + 1) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
ActionKey(
|
||||
icon = Icons.Default.Close,
|
||||
label = "Clear",
|
||||
contentDescription = "Clear the whole query",
|
||||
onClick = onClear,
|
||||
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 2) },
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.focusProperties {
|
||||
right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
|
||||
}
|
||||
.then(
|
||||
if (lastKeyIndex == ACTION_ROW_INDEX + 2) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardKey(
|
||||
label: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.clip(RoundedCornerShape(8.dp)),
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// 44dp of target at six columns across a third of a 1080p screen: large
|
||||
// enough to hit reliably while glancing at the results, not the keyboard.
|
||||
.height(36.dp)
|
||||
.background(if (focused) KeyFocused else KeyIdle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionKey(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.clip(RoundedCornerShape(8.dp)),
|
||||
) { focused ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(if (focused) KeyFocused else KeyIdle),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (focused) KeyLabelFocused else KeyLabel,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A quiet pulse beside the query. Deliberately not a full-screen spinner. */
|
||||
@Composable
|
||||
private fun LoadingDot() {
|
||||
val alpha = rememberInfiniteTransition(label = "search-loading").animateFloat(
|
||||
initialValue = 0.25f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(620), RepeatMode.Reverse),
|
||||
label = "search-loading-alpha",
|
||||
)
|
||||
// Read in the draw lambda: a spinner beside the query must not recompose the pane
|
||||
// that holds the keyboard.
|
||||
Box(
|
||||
Modifier
|
||||
.size(9.dp)
|
||||
.drawBehind { drawCircle(color = Accent.copy(alpha = alpha.value)) }
|
||||
.semantics { contentDescription = "Searching" },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultsPane(
|
||||
state: SearchUiState,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
discoveryItems: List<BaseItem>,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 32.dp, top = 28.dp, bottom = 12.dp),
|
||||
) {
|
||||
val columns = if (maxWidth > 760.dp) 5 else 4
|
||||
val cardWidth = ((maxWidth - CARD_SPACING * (columns - 1)) / columns)
|
||||
.coerceIn(120.dp, 200.dp)
|
||||
|
||||
// Discovery, results, error and "no matches" all share this pane. Only the
|
||||
// heading and the item source change, so the grid never unmounts and remounts.
|
||||
val showingDiscovery = state.isDiscovery
|
||||
val items = if (showingDiscovery) discoveryItems else state.results
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
ResultsHeading(state = state, showingDiscovery = showingDiscovery)
|
||||
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
||||
if (showingDiscovery && genres.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GenreTiles(
|
||||
genres = genres,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
onSelected = onSuggestionSelected,
|
||||
)
|
||||
}
|
||||
if (showingDiscovery && recent.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"RECENT SEARCHES",
|
||||
color = Muted,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SuggestionChips(
|
||||
suggestions = recent,
|
||||
resultsEntry = null,
|
||||
keyboardReturn = keyboardReturn,
|
||||
onSelected = onSuggestionSelected,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when {
|
||||
state.errorMessage != null && state.results.isEmpty() -> SearchError(
|
||||
message = state.errorMessage,
|
||||
onRetry = onRetry,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
)
|
||||
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
|
||||
else -> ResultsGrid(
|
||||
items = items,
|
||||
columns = columns,
|
||||
cardWidth = cardWidth,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
returnFocusItemId = returnFocusItemId,
|
||||
returnFocusRequester = returnFocusRequester,
|
||||
onItemFocused = onItemFocused,
|
||||
onItemSelected = onItemSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) {
|
||||
val title = when {
|
||||
showingDiscovery -> "Browse your library"
|
||||
state.isEmptyResult -> "Search results — no matches"
|
||||
else -> "Search results for “${state.query.trim()}”"
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
title,
|
||||
color = Heading,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (!showingDiscovery && state.results.isNotEmpty()) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultsGrid(
|
||||
items: List<BaseItem>,
|
||||
columns: Int,
|
||||
cardWidth: Dp,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val gridState = rememberLazyGridState()
|
||||
|
||||
// Warm the first screenful so the grid does not fill in card by card. Keyed on the
|
||||
// ids rather than the list, so an unchanged result set never re-fetches.
|
||||
val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } }
|
||||
LaunchedEffect(prefetchKey, cardWidth) {
|
||||
val repo = ServiceLocator.repository
|
||||
val widthPx = with(density) { cardWidth.roundToPx() }.coerceIn(180, 720)
|
||||
items.take(PREFETCHED_POSTERS).forEach { item ->
|
||||
val url = repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
|
||||
?: return@forEach
|
||||
context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.size(widthPx, (widthPx * 3f / 2f).toInt())
|
||||
.allowHardware(true)
|
||||
.crossfade(false)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
state = gridState,
|
||||
horizontalArrangement = Arrangement.spacedBy(CARD_SPACING),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
// itemsIndexed, not items + indexOf: BaseItem is a data class, so indexOf would
|
||||
// run a deep equals per card per composition on the app's hottest new screen.
|
||||
itemsIndexed(items, key = { _, item -> item.id }, contentType = { _, _ -> "search-result" }) { index, item ->
|
||||
PosterGridCard(
|
||||
item = item,
|
||||
width = cardWidth,
|
||||
onFocused = { onItemFocused(item) },
|
||||
onClick = { onItemSelected(item) },
|
||||
onLongClick = { onItemSelected(item) },
|
||||
modifier = Modifier
|
||||
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.then(
|
||||
if (item.id == returnFocusItemId) {
|
||||
Modifier.focusRequester(returnFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
// Leftmost column goes back to the keyboard rather than nowhere.
|
||||
.focusProperties {
|
||||
if (index % columns == 0) left = keyboardReturn
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val GenreColors = listOf(
|
||||
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
|
||||
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
|
||||
)
|
||||
|
||||
private fun genreIcon(label: String): ImageVector = when {
|
||||
label.contains("comedy", true) -> Icons.Default.TheaterComedy
|
||||
label.contains("music", true) -> Icons.Default.LiveTv
|
||||
label.contains("children", true) || label.contains("family", true) -> Icons.Default.SentimentVerySatisfied
|
||||
label.contains("sport", true) -> Icons.Default.PlayCircleFilled
|
||||
label.contains("document", true) -> Icons.Default.Movie
|
||||
else -> Icons.Default.AutoAwesome
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreTiles(
|
||||
genres: List<SearchSuggestion>,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rowItemsIndexed(genres.take(6), key = { _, item -> item.label }) { index, genre ->
|
||||
val color = GenreColors[index % GenreColors.size]
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { onSelected(genre.label) },
|
||||
contentDescription = "Search the ${genre.label} genre",
|
||||
modifier = Modifier
|
||||
.width(126.dp)
|
||||
.height(72.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.focusProperties { if (index == 0) left = keyboardReturn },
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focused) color.copy(alpha = 0.95f) else color),
|
||||
) {
|
||||
Icon(
|
||||
genreIcon(genre.label),
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(10.dp)
|
||||
.size(30.dp),
|
||||
)
|
||||
Text(
|
||||
genre.label,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionChips(
|
||||
suggestions: List<SearchSuggestion>,
|
||||
resultsEntry: FocusRequester?,
|
||||
keyboardReturn: FocusRequester,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
suggestions.take(6).forEachIndexed { index, suggestion ->
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { onSelected(suggestion.label) },
|
||||
contentDescription = when (suggestion.kind) {
|
||||
SearchSuggestion.Kind.RECENT -> "Search again for ${suggestion.label}"
|
||||
SearchSuggestion.Kind.GENRE -> "Search the ${suggestion.label} genre"
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.then(if (index == 0 && resultsEntry != null) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.focusProperties { if (index == 0) left = keyboardReturn },
|
||||
) { focused ->
|
||||
Text(
|
||||
suggestion.label.uppercase(),
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
|
||||
val message = when {
|
||||
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
|
||||
state.isLoading -> "Searching…"
|
||||
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
|
||||
}
|
||||
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchError(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
) {
|
||||
Column(Modifier.padding(top = 36.dp)) {
|
||||
Text(message, color = Heading, fontSize = 18.sp)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onRetry,
|
||||
contentDescription = "Try the search again",
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.focusRequester(resultsEntry)
|
||||
.focusProperties { left = keyboardReturn },
|
||||
) { focused ->
|
||||
Text(
|
||||
"Try again",
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(horizontal = 22.dp, vertical = 11.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CARD_SPACING = 16.dp
|
||||
private const val KEYBOARD_COLUMNS = 6
|
||||
private const val ACTION_ROW_INDEX = 36
|
||||
private const val BACKSPACE_CODE = 8
|
||||
private const val FIRST_PRINTABLE_CODE = 32
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
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.BaseItem
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
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
|
||||
|
||||
/**
|
||||
* Something to offer when there is nothing to search for yet. Kept deliberately cheap:
|
||||
* both kinds are built from data the app already has in memory, because an empty search
|
||||
* box is not worth a server request.
|
||||
*/
|
||||
data class SearchSuggestion(val label: String, val kind: Kind) {
|
||||
enum class Kind { RECENT, GENRE }
|
||||
}
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<BaseItem> = emptyList(),
|
||||
val suggestions: List<SearchSuggestion> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
||||
val hasSearched: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
/** The query is long enough to search but nothing came back. */
|
||||
val isEmptyResult: Boolean
|
||||
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
|
||||
|
||||
/** Nothing typed yet: the pane shows discovery rather than results. */
|
||||
val isDiscovery: Boolean
|
||||
get() = !shouldSearch(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant search.
|
||||
*
|
||||
* Typing feeds [onQueryChanged]; a debounce, a `distinctUntilChanged` and a
|
||||
* `collectLatest` do the rest. The last of those is what makes rapid typing safe: it
|
||||
* cancels the in-flight request when a newer query arrives, so a slow response for "bre"
|
||||
* can never overwrite the results for "break".
|
||||
*/
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
val state: StateFlow<SearchUiState> = _state.asStateFlow()
|
||||
|
||||
private val queryFlow = MutableStateFlow("")
|
||||
|
||||
/**
|
||||
* Results for queries typed earlier in this session. Bounded and access-ordered, so
|
||||
* backspacing through a word redraws instantly instead of re-querying every prefix.
|
||||
*/
|
||||
private val cache = object : LinkedHashMap<String, List<BaseItem>>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(
|
||||
eldest: MutableMap.MutableEntry<String, List<BaseItem>>?,
|
||||
): Boolean = size > CACHE_ENTRIES
|
||||
}
|
||||
|
||||
private val recentQueries = ArrayDeque<String>()
|
||||
private var genreSuggestions: List<String> = emptyList()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
queryFlow
|
||||
.debounce(DEBOUNCE_MS)
|
||||
.map { it.trim() }
|
||||
.distinctUntilChanged()
|
||||
// collectLatest rather than flatMapLatest: the search is a one-shot
|
||||
// suspend call, not a flow, and this cancels the previous one on the
|
||||
// same terms while keeping the repository's plain suspend signature.
|
||||
.collectLatest { term -> runSearch(term) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.getRecentSearches().forEach { term ->
|
||||
if (recentQueries.none { it.equals(term, ignoreCase = true) }) {
|
||||
recentQueries.addLast(term)
|
||||
}
|
||||
}
|
||||
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
|
||||
refreshSuggestions()
|
||||
}
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
||||
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
|
||||
fun onQueryChanged(query: String) {
|
||||
// The visible field updates immediately; only the *search* is debounced.
|
||||
_state.update { it.copy(query = query) }
|
||||
queryFlow.value = query
|
||||
}
|
||||
|
||||
fun appendToQuery(text: String) = onQueryChanged(state.value.query + text)
|
||||
|
||||
fun backspace() = onQueryChanged(state.value.query.dropLast(1))
|
||||
|
||||
fun clearQuery() {
|
||||
// Clear immediately so results from a genre tile cannot remain visible while the
|
||||
// debounced empty-query transition is pending.
|
||||
_state.update { it.copy(query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) }
|
||||
queryFlow.value = ""
|
||||
}
|
||||
|
||||
/** Retry after an error, without disturbing the query or the keyboard. */
|
||||
fun retry() {
|
||||
val term = state.value.query.trim()
|
||||
if (!shouldSearch(term)) return
|
||||
cache.remove(term)
|
||||
viewModelScope.launch { runSearch(term) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Genre chips for the empty state, taken from items the home screen already loaded.
|
||||
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
|
||||
*/
|
||||
fun setDiscoveryItems(items: List<BaseItem>) {
|
||||
val genres = items.asSequence()
|
||||
.flatMap { it.genres.asSequence() }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(MAX_GENRE_SUGGESTIONS)
|
||||
.map { it.key }
|
||||
if (genres != genreSuggestions) {
|
||||
genreSuggestions = genres
|
||||
refreshSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runSearch(term: String) {
|
||||
if (!shouldSearch(term)) {
|
||||
// Back to the discovery state, but the previous results are dropped rather
|
||||
// than left behind a shorter query they no longer match.
|
||||
_state.update {
|
||||
it.copy(results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cache[term]?.let { cached ->
|
||||
_state.update {
|
||||
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Previous results stay on screen underneath the spinner: a flash of empty pane
|
||||
// between two letters reads as breakage, not as progress.
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
runCatching { repository.search(term) }
|
||||
.onSuccess { items ->
|
||||
val ranked = rankSearchResults(term, items)
|
||||
cache[term] = ranked
|
||||
rememberQuery(term)
|
||||
viewModelScope.launch { repository.recordSearch(term) }
|
||||
_state.update {
|
||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
// A cancelled search is the normal case while typing, not a failure.
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
_state.update {
|
||||
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberQuery(term: String) {
|
||||
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
|
||||
recentQueries.addFirst(term)
|
||||
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
||||
private fun refreshSuggestions() {
|
||||
val suggestions = recentQueries.map { SearchSuggestion(it, SearchSuggestion.Kind.RECENT) } +
|
||||
genreSuggestions.map { SearchSuggestion(it, SearchSuggestion.Kind.GENRE) }
|
||||
_state.update { it.copy(suggestions = suggestions) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEBOUNCE_MS = 250L
|
||||
const val MIN_QUERY_LENGTH = 2
|
||||
private const val CACHE_ENTRIES = 24
|
||||
private const val MAX_RECENT_QUERIES = 6
|
||||
private const val MAX_GENRE_SUGGESTIONS = 6
|
||||
}
|
||||
}
|
||||
|
||||
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Two characters, not one.
|
||||
*
|
||||
* A single letter matches a large fraction of any library, so the request is slow, the
|
||||
* results are noise, and it fires on the way to every real query. Multi-word queries are
|
||||
* untouched — only the trimmed length matters.
|
||||
*/
|
||||
fun shouldSearch(query: String): Boolean = query.trim().length >= SearchViewModel.MIN_QUERY_LENGTH
|
||||
|
||||
/**
|
||||
* Orders results so the obvious answer is first.
|
||||
*
|
||||
* The backend already sorts by its own relevance (Postgres `ts_rank`, or Emby's own
|
||||
* ordering), and that ordering is *preserved within each tier* — this only lifts the
|
||||
* matches a person would be annoyed to find below the fold. Sorting is stable, so a
|
||||
* backend that already got it right is not reshuffled.
|
||||
*/
|
||||
fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
|
||||
val term = query.trim().lowercase()
|
||||
if (term.isEmpty()) return items
|
||||
return items.sortedBy { item -> matchTier(term, item) }
|
||||
}
|
||||
|
||||
private fun matchTier(term: String, item: BaseItem): Int {
|
||||
val name = item.name.trim().lowercase()
|
||||
val series = item.seriesName.orEmpty().trim().lowercase()
|
||||
return when {
|
||||
name == term -> 0
|
||||
series == term -> 1
|
||||
name.startsWith(term) -> 2
|
||||
series.startsWith(term) -> 3
|
||||
name.containsWordStartingWith(term) -> 4
|
||||
name.contains(term) -> 5
|
||||
series.contains(term) -> 6
|
||||
// Everything else the backend returned: genre, year or overview matches. Kept,
|
||||
// because "no exact match but here is the related thing" beats an empty pane.
|
||||
else -> 7
|
||||
}
|
||||
}
|
||||
|
||||
/** "star" should rank higher in "Lone Star" than in "Costar". */
|
||||
private fun String.containsWordStartingWith(term: String): Boolean =
|
||||
split(' ', '-', ':', '.', '\'').any { it.startsWith(term) }
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The focused pill is white, so its label has to go dark to stay readable. -->
|
||||
<item android:state_focused="true" android:color="#FF0B0E11" />
|
||||
<item android:color="#FFFFFFFF" />
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#FF090B0D" android:state_focused="true" />
|
||||
<item android:color="#FFFFFFFF" />
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#FF090B0D" android:state_focused="true" />
|
||||
<item android:color="#FFFFFFFF" />
|
||||
</selector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.42,-1.41L7.83,13H20v-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F2101418" />
|
||||
<corners android:radius="14dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#26FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Focused reads as a filled white pill, matching the transport controls: on a TV the
|
||||
focused item has to be unmistakable from across the room. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FF52B54B" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#28FFFFFF" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FF24292E" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#24FFFFFF" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#FF52B54B" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#28FFFFFF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:endColor="#F2000000"
|
||||
android:startColor="#00000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#D9000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#2E52B54B" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#B852B54B" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#16FFFFFF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#20FFFFFF" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FA0B0E11" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#24FFFFFF" />
|
||||
<corners
|
||||
android:bottomLeftRadius="24dp"
|
||||
android:topLeftRadius="24dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<gradient
|
||||
android:angle="315"
|
||||
android:centerColor="#FF0D1114"
|
||||
android:endColor="#FF050708"
|
||||
android:startColor="#FF171D21"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#38000000" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#28FFFFFF" />
|
||||
<corners android:radius="14dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,161 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- <merge> rather than a FrameLayout root: setContentView already gives us a full-screen
|
||||
FrameLayout to merge into, so this saves a redundant level in the view hierarchy. -->
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/player_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
app:auto_show="true"
|
||||
app:controller_layout_id="@layout/memby_player_controls"
|
||||
app:hide_on_touch="true"
|
||||
app:keep_content_on_player_reset="true"
|
||||
app:resize_mode="fit"
|
||||
app:show_buffering="never"
|
||||
app:surface_type="surface_view"
|
||||
app:use_controller="true" />
|
||||
|
||||
<!-- Above the video, below the loading overlay: a slide that is still starting has
|
||||
nothing to say about what comes next. -->
|
||||
<include layout="@layout/player_next_up_banner" />
|
||||
|
||||
<!-- App-owned subtitle controls. ExoPlayer supplies tracks and rendering only; this
|
||||
overlay deliberately stays in Memby's TV design language. -->
|
||||
<include layout="@layout/player_subtitle_overlay" />
|
||||
|
||||
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
|
||||
<include layout="@layout/player_cast_overlay" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/playback_loading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F2050708"
|
||||
android:focusable="false"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/playback_loading_logo"
|
||||
android:layout_width="82dp"
|
||||
android:layout_height="70dp"
|
||||
android:contentDescription="@string/playback_loading"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/playback_loading"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:text="@string/playback_loading_hint"
|
||||
android:textColor="#FF929AA0"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- One cheap overlay, no second ExoPlayer. The programme prepares underneath it. -->
|
||||
<include layout="@layout/player_preroll" />
|
||||
|
||||
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
|
||||
actionable after Media3 hides its transport controls. -->
|
||||
<FrameLayout
|
||||
android:id="@+id/playback_error"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F2050708"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="600dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="36dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="62dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_error_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_error_detail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:textColor="#BFFFFFFF"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/playback_error_retry"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/next_up_primary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/playback_try_again"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/playback_error_exit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:background="@drawable/next_up_secondary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/playback_back_to_memby"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
</merge>
|
||||
@@ -0,0 +1,246 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- This is a PlayerView controller layout (app:controller_layout_id), so it is bound to
|
||||
media3-ui's exo_* ids by contract. The transport buttons reuse media3's own icons and
|
||||
descriptions for the same reason; they are marked private, hence the tools:ignore. If a
|
||||
media3 upgrade ever drops one, copy it into res/ here rather than un-suppressing. -->
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<View
|
||||
android:id="@id/exo_controls_background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="210dp"
|
||||
android:layout_gravity="top"
|
||||
android:background="@drawable/player_osd_top_gradient" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@drawable/player_osd_bottom_gradient" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="140dp"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="30dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/player_now_playing"
|
||||
android:textColor="#B8FFFFFF"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_title_logo"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="92dp"
|
||||
android:layout_marginTop="7dp"
|
||||
android:adjustViewBounds="true"
|
||||
android:contentDescription="@string/player_title_logo"
|
||||
android:scaleType="fitStart"
|
||||
android:visibility="invisible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:maxWidth="600dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_stream_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="44dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:background="@drawable/player_status_background"
|
||||
android:letterSpacing="0.08"
|
||||
android:paddingStart="13dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingEnd="13dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:textColor="#DFFFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="210dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="48dp"
|
||||
android:paddingEnd="48dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@id/exo_position"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="6dp"
|
||||
android:paddingEnd="6dp"
|
||||
android:text="@string/player_position_separator"
|
||||
android:textColor="#80FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@id/exo_duration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#AFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_remaining"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:text="@string/player_loading_duration"
|
||||
android:textColor="#BFFFFFFF"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_finish_time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.media3.ui.DefaultTimeBar
|
||||
android:id="@id/exo_progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="34dp"
|
||||
android:focusable="true"
|
||||
app:ad_marker_color="#80FFFFFF"
|
||||
app:bar_height="4dp"
|
||||
app:buffered_color="#70FFFFFF"
|
||||
app:played_ad_marker_color="#FFFFFFFF"
|
||||
app:played_color="#FF52B54B"
|
||||
app:scrubber_color="#FFFFFFFF"
|
||||
app:scrubber_dragged_size="18dp"
|
||||
app:scrubber_enabled_size="14dp"
|
||||
app:touch_target_height="28dp"
|
||||
app:unplayed_color="#40FFFFFF" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:focusable="false">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/player_exit"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/player_back"
|
||||
android:src="@drawable/ic_player_back" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/player_hide_controls"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/player_hide_controls"
|
||||
android:src="@drawable/ic_player_hide" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@id/exo_center_controls"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_rew"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_rewind_description"
|
||||
android:src="@drawable/exo_icon_rewind"
|
||||
tools:ignore="PrivateResource" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_play_pause"
|
||||
style="@style/MembyPlayerPrimaryControlButton"
|
||||
android:contentDescription="@string/exo_controls_play_description"
|
||||
android:focusedByDefault="true"
|
||||
android:src="@drawable/exo_icon_play"
|
||||
tools:ignore="PrivateResource,UnusedAttribute" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_ffwd"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_fastforward_description"
|
||||
android:src="@drawable/exo_icon_fastforward"
|
||||
tools:ignore="PrivateResource" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_subtitle"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_cc_enabled_description"
|
||||
android:src="@drawable/exo_ic_subtitle_on"
|
||||
tools:ignore="PrivateResource" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_settings"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_settings_description"
|
||||
android:src="@drawable/exo_ic_settings"
|
||||
tools:ignore="PrivateResource" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_cast_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#70000000"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="340dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@drawable/player_overlay_panel_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="48dp"
|
||||
android:paddingTop="25dp"
|
||||
android:paddingEnd="48dp"
|
||||
android:paddingBottom="22dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/player_now_playing"
|
||||
android:textColor="#FF52B54B"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/player_cast"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_cast_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/player_cast_loading"
|
||||
android:textColor="#AFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="false"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_cast_people"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="top"
|
||||
android:orientation="horizontal" />
|
||||
</HorizontalScrollView>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/player_back_to_close"
|
||||
android:textColor="#78FFFFFF"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A shallow native end-of-episode overlay. The outgoing PlayerView is scaled into the
|
||||
open left side while this single prefetched episode card fades in. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/player_next_up"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="420dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
android:layout_marginEnd="50dp"
|
||||
android:background="@drawable/next_up_banner_background"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_next_up_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="214dp"
|
||||
android:background="#FF1B2026"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/next_up_label"
|
||||
android:textColor="#FF69CD61"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_next_up_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_next_up_meta"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#BFFFFFFF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_next_up_countdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textColor="#FF69CD61"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="horizontal"
|
||||
tools:ignore="ButtonStyle">
|
||||
|
||||
<Button
|
||||
android:id="@+id/player_next_up_play"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/next_up_primary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="22dp"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingEnd="22dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/next_up_play_now"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/player_next_up_dismiss"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:background="@drawable/next_up_secondary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="22dp"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingEnd="22dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/next_up_dismiss"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_preroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FF050708"
|
||||
android:clickable="true"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="64dp"
|
||||
android:paddingTop="54dp"
|
||||
android:paddingEnd="64dp"
|
||||
android:paddingBottom="54dp">
|
||||
|
||||
<!-- Reserved for the eventual pre-roll media asset. Keeping this a plain View is
|
||||
virtually free and lets the real programme buffer beneath the overlay. -->
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="52dp"
|
||||
android:layout_weight="1.62"
|
||||
android:background="@drawable/player_preroll_video_background">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="74dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_gravity="start|bottom"
|
||||
android:layout_marginStart="28dp"
|
||||
android:layout_marginBottom="25dp"
|
||||
android:alpha="0.24"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_countdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_marginEnd="28dp"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:background="@drawable/player_status_background"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/player_preroll_countdown_initial"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Coming up"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:text="From your Sonarr calendar"
|
||||
android:textColor="#FF8E979D"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_today_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="25dp"
|
||||
android:text="AIRING TODAY"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF55B94D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_today"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_week_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:text="THIS WEEK"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF8E979D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_week"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_subtitle_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#70000000"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="520dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginStart="30dp"
|
||||
android:background="@drawable/player_overlay_panel_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="34dp"
|
||||
android:paddingTop="32dp"
|
||||
android:paddingEnd="34dp"
|
||||
android:paddingBottom="26dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/player_playback_options"
|
||||
android:textColor="#FF52B54B"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:text="@string/player_subtitles"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:text="@string/player_subtitle_overlay_hint"
|
||||
android:textColor="#AFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.12"
|
||||
android:text="@string/player_subtitle_track"
|
||||
android:textColor="#8FFFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_subtitle_tracks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="9dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="25dp"
|
||||
android:letterSpacing="0.12"
|
||||
android:text="@string/player_text_size"
|
||||
android:textColor="#8FFFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_subtitle_sizes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="9dp"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/player_back_to_close"
|
||||
android:textColor="#78FFFFFF"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="MembyPlayerControlButton">
|
||||
<item name="android:layout_width">48dp</item>
|
||||
<item name="android:layout_height">48dp</item>
|
||||
<item name="android:layout_marginEnd">10dp</item>
|
||||
<item name="android:background">@drawable/player_control_button_background</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:padding">13dp</item>
|
||||
<item name="android:tint">@color/player_control_tint</item>
|
||||
</style>
|
||||
|
||||
<style name="MembyPlayerPrimaryControlButton" parent="MembyPlayerControlButton">
|
||||
<item name="android:layout_width">60dp</item>
|
||||
<item name="android:layout_height">60dp</item>
|
||||
<item name="android:layout_marginEnd">14dp</item>
|
||||
<item name="android:padding">15dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CastMetadataTest {
|
||||
|
||||
@Test
|
||||
fun `cast contains actors and preserves Emby order`() {
|
||||
val item = BaseItem(
|
||||
id = "movie",
|
||||
people = listOf(
|
||||
EmbyPerson(id = "director", name = "Director", type = "Director"),
|
||||
EmbyPerson(id = "lead", name = "Lead", role = "Detective", type = "Actor"),
|
||||
EmbyPerson(id = "support", name = "Support", role = "Doctor", type = "Actor"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("lead", "support"), item.cast.map(EmbyPerson::id))
|
||||
assertEquals("Detective", item.cast.first().role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes emby people and portrait tags`() {
|
||||
val item = Json { ignoreUnknownKeys = true }.decodeFromString<BaseItem>(
|
||||
"""
|
||||
{
|
||||
"Id":"movie",
|
||||
"Name":"Example",
|
||||
"Type":"Movie",
|
||||
"People":[{
|
||||
"Id":"person-1",
|
||||
"Name":"Alex Actor",
|
||||
"Role":"Morgan",
|
||||
"Type":"Actor",
|
||||
"PrimaryImageTag":"portrait-tag"
|
||||
}]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("person-1", item.cast.single().id)
|
||||
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayAlert
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ServiceAlertTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
|
||||
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message)
|
||||
|
||||
@Test
|
||||
fun `picks the first alert this tv has not seen`() {
|
||||
val chosen = firstUnseenAlert(
|
||||
listOf(alert("a"), alert("b"), alert("c")),
|
||||
seen = setOf("a", "b"),
|
||||
)
|
||||
assertEquals("c", chosen?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert already shown never comes back`() {
|
||||
assertNull(firstUnseenAlert(listOf(alert("a")), seen = setOf("a")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incomplete alerts are dropped rather than drawn empty`() {
|
||||
val alerts = listOf(
|
||||
alert(id = ""),
|
||||
alert(id = "b", title = " "),
|
||||
alert(id = "c", message = ""),
|
||||
alert(id = "d"),
|
||||
)
|
||||
assertEquals("d", firstUnseenAlert(alerts, seen = emptySet())?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert still on offer keeps waiting for a screen`() {
|
||||
assertFalse(
|
||||
pendingAlertExpired(
|
||||
pendingId = "a",
|
||||
shownId = null,
|
||||
alerts = listOf(alert("a"), alert("b")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unshown alert is given up on once the gateway stops offering it`() {
|
||||
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = listOf(alert("b"))))
|
||||
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert already on screen is left to its own timer`() {
|
||||
assertFalse(pendingAlertExpired(pendingId = "a", shownId = "a", alerts = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes without alerts for a gateway that predates them`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"message":""}""",
|
||||
)
|
||||
assertTrue(status.alerts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes the alert payload the gateway sends`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""
|
||||
{"maintenance":false,"message":"","alerts":[{
|
||||
"id":"sonarr:7:42:aired","kind":"sonarr-aired","title":"Northbound",
|
||||
"message":"S02E04 aired at 9:00 PM and will be in Emby soon.",
|
||||
"itemId":"sonarr:7:42","imageTag":"sonarr","airedAt":"2026-07-27T21:00:00+12:00"
|
||||
}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val alert = status.alerts.single()
|
||||
assertEquals("sonarr:7:42:aired", alert.id)
|
||||
assertEquals("sonarr:7:42", alert.itemId)
|
||||
assertEquals("sonarr", alert.imageTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubtitleSupportTest {
|
||||
@Test
|
||||
fun `normalizes relative delivery urls and preserves selection metadata`() {
|
||||
val tracks = subtitleTracks(
|
||||
streams = listOf(
|
||||
MediaStream(
|
||||
index = 3,
|
||||
type = "Subtitle",
|
||||
codec = "srt",
|
||||
displayTitle = "English SDH",
|
||||
language = "eng",
|
||||
isDefault = true,
|
||||
isForced = true,
|
||||
isTextSubtitleStream = true,
|
||||
deliveryUrl = "/Videos/a/Subtitles/3/Stream.srt?x=1",
|
||||
),
|
||||
),
|
||||
serverUrl = "https://emby.example",
|
||||
token = "a b",
|
||||
itemId = "item",
|
||||
mediaSourceId = "source",
|
||||
)
|
||||
|
||||
assertEquals(1, tracks.size)
|
||||
assertEquals(
|
||||
"https://emby.example/Videos/a/Subtitles/3/Stream.srt?x=1&api_key=a+b",
|
||||
tracks.single().url,
|
||||
)
|
||||
assertEquals("application/x-subrip", tracks.single().mimeType)
|
||||
assertEquals("eng", tracks.single().language)
|
||||
assertTrue(tracks.single().isDefault)
|
||||
assertTrue(tracks.single().isForced)
|
||||
assertTrue(tracks.single().isHearingImpaired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps supported text subtitles and rejects bitmap formats`() {
|
||||
assertEquals("text/vtt", subtitleMimeType("webvtt"))
|
||||
assertEquals("text/x-ssa", subtitleMimeType(null, "https://host/subtitle.ass?token=x"))
|
||||
assertEquals(null, subtitleMimeType("pgssub"))
|
||||
assertEquals(null, subtitleMimeType("dvdsub"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not duplicate authentication already present`() {
|
||||
assertEquals(
|
||||
"https://host/sub.srt?api_key=existing",
|
||||
authenticatedDeliveryUrl("https://other", "https://host/sub.srt?api_key=existing", "new"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image subtitles remain visible as encode choices without an overlay url`() {
|
||||
val track = subtitleTracks(
|
||||
streams = listOf(
|
||||
MediaStream(
|
||||
index = 7,
|
||||
type = "Subtitle",
|
||||
codec = "pgssub",
|
||||
language = "eng",
|
||||
isForced = true,
|
||||
deliveryMethod = "Encode",
|
||||
),
|
||||
),
|
||||
serverUrl = "https://emby.example",
|
||||
token = "token",
|
||||
itemId = "item",
|
||||
mediaSourceId = "source",
|
||||
).single()
|
||||
|
||||
assertEquals("Encode", track.deliveryMethod)
|
||||
assertEquals("", track.url)
|
||||
assertEquals("", track.mimeType)
|
||||
assertTrue(track.isForced)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AiringTodayTagsTest {
|
||||
@Test
|
||||
fun `tags matching recommended series from today's Sonarr schedule`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
kind = "schedule",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:7:42",
|
||||
name = "The Bear",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
kind = "recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "The Bear", type = "Series"),
|
||||
BaseItem(id = "series-2", name = "Severance", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val recommendations = home.withAiringTodayTags().rows.last().items
|
||||
|
||||
assertTrue(recommendations[0].membyAiringToday)
|
||||
assertFalse(recommendations[1].membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show matching ignores punctuation spacing and case`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:2",
|
||||
name = "Marvel's DAREDEVIL",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "Marvels Daredevil", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not tag movies with a matching title`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:2",
|
||||
name = "Fargo",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(BaseItem(id = "movie-1", name = "Fargo", type = "Movie")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileViewModelStoreOwnerTest {
|
||||
private val factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
ProbeViewModel() as T
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profiles have isolated view models and clearing cancels the old one`() {
|
||||
val mattOwner = ProfileViewModelStoreOwner()
|
||||
val familyOwner = ProfileViewModelStoreOwner()
|
||||
|
||||
val matt = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
|
||||
val mattAgain = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
|
||||
val family = ViewModelProvider(familyOwner, factory)[ProbeViewModel::class.java]
|
||||
|
||||
assertSame(matt, mattAgain)
|
||||
assertNotSame(matt, family)
|
||||
assertFalse(matt.cleared)
|
||||
assertFalse(family.cleared)
|
||||
|
||||
mattOwner.clear()
|
||||
|
||||
assertTrue(matt.cleared)
|
||||
assertFalse(family.cleared)
|
||||
}
|
||||
|
||||
private class ProbeViewModel : ViewModel() {
|
||||
var cleared = false
|
||||
private set
|
||||
|
||||
override fun onCleared() {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SeriesDetailsTest {
|
||||
private fun episode(
|
||||
id: String,
|
||||
season: Int,
|
||||
number: Int,
|
||||
played: Boolean = false,
|
||||
) = BaseItem(
|
||||
id = id,
|
||||
name = "Episode $number",
|
||||
type = "Episode",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
userData = UserItemData(played = played),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `seasons are distinct and ordered with specials first`() {
|
||||
val episodes = listOf(
|
||||
episode("s2e1", 2, 1),
|
||||
episode("s0e1", 0, 1),
|
||||
episode("s1e1", 1, 1),
|
||||
episode("s2e2", 2, 2),
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 1, 2), availableSeasons(episodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `season list is ordered by episode number`() {
|
||||
val episodes = listOf(
|
||||
episode("e3", 1, 3),
|
||||
episode("e1", 1, 1),
|
||||
episode("e2", 1, 2),
|
||||
)
|
||||
|
||||
assertEquals(listOf("e1", "e2", "e3"), episodesForSeason(episodes, 1).map(BaseItem::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default season contains the first unwatched episode`() {
|
||||
val episodes = listOf(
|
||||
episode("s1e1", 1, 1, played = true),
|
||||
episode("s1e2", 1, 2, played = true),
|
||||
episode("s2e1", 2, 1),
|
||||
)
|
||||
|
||||
assertEquals(2, defaultSeason(episodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detail tabs default safely to episodes`() {
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
|
||||
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
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 the aired banner to PNGs under `build/screenshots/`, so its layout can be
|
||||
* looked at without deploying to a TV.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*ServiceAlertBannerScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* This is the **only** part of `app/src/test` that touches Android — rendering a
|
||||
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
|
||||
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
|
||||
*
|
||||
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
|
||||
* own background, flush to the top edge exactly as `MainActivity` places it.
|
||||
*
|
||||
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
|
||||
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
|
||||
* are null because there is no network here — which makes these the check on the fallback
|
||||
* tile too.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class ServiceAlertBannerScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `awaiting download`() {
|
||||
capture(
|
||||
"alert-banner-awaiting",
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloading now`() {
|
||||
capture(
|
||||
"alert-banner-downloading",
|
||||
ServiceAlert(
|
||||
id = "sonarr:8:43:aired",
|
||||
title = "The Long Dark",
|
||||
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Both lines ellipsise at one line; this is the case that proves it. */
|
||||
@Test
|
||||
fun `long title and message`() {
|
||||
capture(
|
||||
"alert-banner-long-text",
|
||||
ServiceAlert(
|
||||
id = "sonarr:9:88:aired",
|
||||
title = "A Very Long Programme Title That Will Not Fit In One Line",
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"And Then Some More Happens After That aired at 10:30 PM and is " +
|
||||
"downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Shortest plausible content: the bar keeps its height and the text stays put. */
|
||||
@Test
|
||||
fun `short title and message`() {
|
||||
capture(
|
||||
"alert-banner-short-text",
|
||||
ServiceAlert(
|
||||
id = "sonarr:3:12:aired",
|
||||
title = "Dune",
|
||||
message = "S01E01 aired and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The countdown ring is full at t=0, which tells you nothing about whether it
|
||||
* empties. Holding the clock and stepping it forward captures it mid-sweep.
|
||||
*/
|
||||
@Test
|
||||
fun `countdown part way through`() {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
AlertBannerOnHomeBackground(
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
compose.mainClock.advanceTimeBy(6_500L)
|
||||
compose.onRoot().captureRoboImage("build/screenshots/alert-banner-countdown.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, alert: ServiceAlert) {
|
||||
compose.setContent { AlertBannerOnHomeBackground(alert) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PictureSizingTest {
|
||||
|
||||
@Test
|
||||
fun `auto fills classic four by three video`() {
|
||||
assertTrue(shouldZoomVideo("auto", 640, 480, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto accounts for anamorphic pixel aspect ratio`() {
|
||||
assertTrue(shouldZoomVideo("auto", 720, 480, 8f / 9f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto preserves modern widescreen video`() {
|
||||
assertFalse(shouldZoomVideo("auto", 1920, 1080, 1f))
|
||||
assertFalse(shouldZoomVideo("auto", 1920, 800, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual modes are deterministic`() {
|
||||
assertFalse(shouldZoomVideo("original", 640, 480, 1f))
|
||||
assertTrue(shouldZoomVideo("fill", 1920, 1080, 1f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackRecoveryTest {
|
||||
@Test
|
||||
fun networkFailuresAreSafeToRetry() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
)
|
||||
|
||||
assertEquals("Connection interrupted", failure.title)
|
||||
assertTrue(failure.canAutoRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderFailuresRequireViewerAction() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
)
|
||||
|
||||
assertEquals("Video format not supported", failure.title)
|
||||
assertFalse(failure.canAutoRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun automaticRetriesAreBoundedAndBackOff() {
|
||||
assertEquals(1_000L, automaticRetryDelayMs(1))
|
||||
assertEquals(3_000L, automaticRetryDelayMs(2))
|
||||
assertNull(automaticRetryDelayMs(3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PlayerTimingTest {
|
||||
@Test
|
||||
fun formatsRemainingTimeForViewerFriendlyDisplay() {
|
||||
assertEquals("Less than a minute remaining", PlayerActivity.formatRemaining(30_000L))
|
||||
assertEquals("42 min remaining", PlayerActivity.formatRemaining(42L * 60_000L))
|
||||
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
|
||||
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class PrerollLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `player preroll and its background inflate`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_preroll,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
),
|
||||
)
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_cast_overlay,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PrerollSequenceTest {
|
||||
@Test
|
||||
fun `handoff waits only for the five second gate`() {
|
||||
assertFalse(prerollCanHandOff(true, false, true))
|
||||
assertTrue(prerollCanHandOff(true, true, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handoff never starts playback in background`() {
|
||||
assertFalse(prerollCanHandOff(true, true, false))
|
||||
assertFalse(prerollCanHandOff(false, true, true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SearchRankingTest {
|
||||
|
||||
private fun item(name: String, series: String? = null, id: String = name) =
|
||||
BaseItem(id = id, name = name, seriesName = series)
|
||||
|
||||
private fun names(items: List<BaseItem>) = items.map { it.name }
|
||||
|
||||
@Test
|
||||
fun `an exact title beats a title that merely starts with the query`() {
|
||||
val ranked = rankSearchResults(
|
||||
"dune",
|
||||
listOf(item("Dune: Part Two"), item("Dunes of Mars"), item("Dune")),
|
||||
)
|
||||
assertEquals("Dune", ranked.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `titles starting with the query come before mid-word matches`() {
|
||||
val ranked = rankSearchResults(
|
||||
"star",
|
||||
listOf(item("Costar"), item("Lone Star"), item("Stargate")),
|
||||
)
|
||||
// Prefix first, then the word-boundary match, then the buried one.
|
||||
assertEquals(listOf("Stargate", "Lone Star", "Costar"), names(ranked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode is found by its series name when its own title does not match`() {
|
||||
val ranked = rankSearchResults(
|
||||
"severance",
|
||||
listOf(item("Defiant Jazz", series = "Severance"), item("Severed Ties")),
|
||||
)
|
||||
assertEquals("Defiant Jazz", ranked.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weaker matches are kept rather than dropped`() {
|
||||
// A genre or overview match the backend returned: last, but still there, because
|
||||
// "no exact match but here is something related" beats an empty pane.
|
||||
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title")))
|
||||
assertEquals(1, ranked.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ranking is stable so the backend's own relevance order survives within a tier`() {
|
||||
val backendOrder = listOf(item("Alien 3"), item("Alien"), item("Aliens"))
|
||||
val ranked = rankSearchResults("alien", backendOrder)
|
||||
// "Alien" is the exact match and is lifted; the other two keep the order the
|
||||
// server chose rather than being re-sorted alphabetically.
|
||||
assertEquals(listOf("Alien", "Alien 3", "Aliens"), names(ranked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty query leaves the list untouched`() {
|
||||
val items = listOf(item("B"), item("A"))
|
||||
assertEquals(items, rankSearchResults(" ", items))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `searching starts at two characters`() {
|
||||
assertFalse(shouldSearch(""))
|
||||
assertFalse(shouldSearch("a"))
|
||||
assertFalse(shouldSearch(" a "))
|
||||
assertTrue(shouldSearch("ab"))
|
||||
assertTrue(shouldSearch(" the wire "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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.ui.PreviewSurface
|
||||
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
|
||||
|
||||
/**
|
||||
* Captures the real stateless Settings panel at TV size. The fixture deliberately mixes
|
||||
* enabled and disabled values so the screenshot proves state is legible without focus;
|
||||
* the first row also owns focus to prove the white focus ring and green active state are
|
||||
* visually distinct.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SettingsSheetScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `active inactive and focused controls`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = false) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-panel.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home overlay width`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = true) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPreviewFixture(overlay: Boolean) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstFocus.requestFocus() }
|
||||
PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) {
|
||||
SettingsPanelContent(
|
||||
state = SettingsPanelState(
|
||||
showLogo = true,
|
||||
autoPlayNext = false,
|
||||
ringColor = "52B54B",
|
||||
homeSections = setOf("continue", "latest"),
|
||||
cardDensity = "standard",
|
||||
showCardMetadata = false,
|
||||
editableServer = true,
|
||||
baseUrl = "https://mserver.example/releases/latest.json",
|
||||
installedVersion = "0.1.60",
|
||||
),
|
||||
actions = SettingsPanelActions(),
|
||||
overlay = overlay,
|
||||
firstFocusRequester = firstFocus,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploys the complete Memby Docker Compose stack to MATT-NAS.
|
||||
|
||||
.DESCRIPTION
|
||||
Packages the local server build context, Docker Compose file and .env.example,
|
||||
then streams them to the NAS over one SSH connection.
|
||||
|
||||
The remote deployment:
|
||||
- checks Docker and Docker Compose;
|
||||
- installs .env from the local .env.example, overwriting the deployed
|
||||
copy, and keeps the old one alongside as .env.previous;
|
||||
- preserves the named database volume;
|
||||
- pulls Redis and PostgreSQL, then builds the Memby server;
|
||||
- starts each service and waits for its health check;
|
||||
- restores the previous application files if activation fails.
|
||||
|
||||
Configuration lives in the local .env.example and is included directly in every
|
||||
deployment. It does not need to be committed or pushed before deployment.
|
||||
|
||||
SSH performs the password prompt directly. The password is never read or stored
|
||||
by this script.
|
||||
|
||||
This deploys the current local working tree, including uncommitted server changes.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180
|
||||
#>
|
||||
|
||||
#Requires -Version 7.2
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $SourceDirectory = $PSScriptRoot,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $RemoteHost = '10.0.0.213',
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $RemoteUser = 'ssh',
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Destination = '/share/Docker/Memby',
|
||||
|
||||
[Parameter()]
|
||||
[ValidateRange(30, 600)]
|
||||
[int] $HealthTimeoutSeconds = 120
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:CurrentStep = 0
|
||||
$script:TotalSteps = 5
|
||||
|
||||
function Write-Banner {
|
||||
Write-Host ''
|
||||
Write-Host '╭─ Memby deployment' -ForegroundColor Magenta
|
||||
Write-Host "│ Source $SourceDirectory (local working tree)" -ForegroundColor DarkGray
|
||||
Write-Host "│ Target ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray
|
||||
Write-Host '╰─' -ForegroundColor Magenta
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Write-Step {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
$script:CurrentStep++
|
||||
Write-Host ("● [{0}/{1}] {2}" -f $script:CurrentStep, $script:TotalSteps, $Message) -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Detail {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
Write-Host " ↳ $Message" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
Write-Host "✓ $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Get-RequiredCommand {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Name
|
||||
)
|
||||
|
||||
$command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $command) {
|
||||
throw "Required command '$Name' was not found in PATH."
|
||||
}
|
||||
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
function Assert-SafeRemoteSettings {
|
||||
if ($RemoteHost -notmatch '^[A-Za-z0-9._:-]+$') {
|
||||
throw "RemoteHost contains unsupported characters: '$RemoteHost'."
|
||||
}
|
||||
|
||||
if ($RemoteUser -notmatch '^[A-Za-z0-9._-]+$') {
|
||||
throw "RemoteUser contains unsupported characters: '$RemoteUser'."
|
||||
}
|
||||
|
||||
if ($Destination -notmatch '^/[A-Za-z0-9._/-]+$') {
|
||||
throw "Destination must be an absolute Linux path using only letters, numbers, '.', '_', '-', and '/'."
|
||||
}
|
||||
|
||||
$segments = $Destination.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
if ($segments -contains '..') {
|
||||
throw "Destination cannot contain '..' path segments."
|
||||
}
|
||||
|
||||
$broadPaths = @(
|
||||
'/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64',
|
||||
'/opt', '/proc', '/root', '/run', '/sbin', '/srv', '/sys', '/tmp',
|
||||
'/usr', '/var'
|
||||
)
|
||||
if ($Destination -in $broadPaths) {
|
||||
throw "Destination '$Destination' is too broad to replace safely."
|
||||
}
|
||||
}
|
||||
|
||||
function New-DeploymentArchive {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $RepositoryDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $ArchivePath
|
||||
)
|
||||
|
||||
& $script:TarCommand -cf $ArchivePath -C $RepositoryDirectory server docker-compose.yml .env.example
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to create the deployment archive (tar exit code $LASTEXITCODE)."
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) {
|
||||
throw 'The deployment archive was not created.'
|
||||
}
|
||||
}
|
||||
|
||||
function Send-ArchiveOverSsh {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $ArchivePath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $RemoteCommand
|
||||
)
|
||||
|
||||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = $script:SshCommand
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.RedirectStandardInput = $true
|
||||
|
||||
# Force interactive password authentication as requested. OpenSSH reads the
|
||||
# password from the console while stdin carries the tar archive.
|
||||
foreach ($argument in @(
|
||||
'-o', 'PubkeyAuthentication=no',
|
||||
'-o', 'PreferredAuthentications=keyboard-interactive,password',
|
||||
'-o', 'NumberOfPasswordPrompts=3',
|
||||
"$RemoteUser@$RemoteHost",
|
||||
$RemoteCommand
|
||||
)) {
|
||||
[void] $startInfo.ArgumentList.Add($argument)
|
||||
}
|
||||
|
||||
$process = [System.Diagnostics.Process]::new()
|
||||
$process.StartInfo = $startInfo
|
||||
$archiveStream = $null
|
||||
$processStarted = $false
|
||||
$exitCode = $null
|
||||
|
||||
try {
|
||||
if (-not $process.Start()) {
|
||||
throw 'SSH could not be started.'
|
||||
}
|
||||
$processStarted = $true
|
||||
|
||||
$archiveStream = [System.IO.File]::OpenRead($ArchivePath)
|
||||
try {
|
||||
$archiveStream.CopyTo($process.StandardInput.BaseStream)
|
||||
}
|
||||
catch [System.IO.IOException] {
|
||||
# The remote side exited before reading the whole archive — a failed early
|
||||
# step, or a shell that rejected the command. Its own message is already on
|
||||
# screen, so let the exit code below do the explaining rather than burying it
|
||||
# under a broken-pipe stack trace.
|
||||
}
|
||||
try { $process.StandardInput.Close() } catch [System.IO.IOException] { }
|
||||
$process.WaitForExit()
|
||||
$exitCode = $process.ExitCode
|
||||
}
|
||||
catch {
|
||||
if ($processStarted -and -not $process.HasExited) {
|
||||
$process.StandardInput.Close()
|
||||
$process.WaitForExit()
|
||||
}
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
if ($archiveStream) {
|
||||
$archiveStream.Dispose()
|
||||
}
|
||||
$process.Dispose()
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Remote deployment failed with SSH exit code $exitCode. Review the last remote step above."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Banner
|
||||
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
Write-Step 'Checking local prerequisites and settings'
|
||||
Assert-SafeRemoteSettings
|
||||
$script:TarCommand = Get-RequiredCommand -Name 'tar'
|
||||
$script:SshCommand = Get-RequiredCommand -Name 'ssh'
|
||||
Write-Detail "SSH $script:SshCommand"
|
||||
Write-Success 'Local prerequisites are ready'
|
||||
Write-Host ''
|
||||
|
||||
$workDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("memby-deploy-" + [guid]::NewGuid().ToString('N'))
|
||||
$archivePath = Join-Path $workDirectory 'memby-deployment.tar'
|
||||
|
||||
try {
|
||||
[void] (New-Item -ItemType Directory -Path $workDirectory)
|
||||
|
||||
Write-Step 'Selecting the local deployment source'
|
||||
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
|
||||
Write-Success "Using $checkoutDirectory"
|
||||
Write-Host ''
|
||||
|
||||
Write-Step 'Validating the Compose deployment payload'
|
||||
$requiredPaths = @(
|
||||
(Join-Path $checkoutDirectory 'server'),
|
||||
(Join-Path $checkoutDirectory 'server/Dockerfile'),
|
||||
(Join-Path $checkoutDirectory 'server/go.mod'),
|
||||
(Join-Path $checkoutDirectory 'docker-compose.yml'),
|
||||
(Join-Path $checkoutDirectory '.env.example')
|
||||
)
|
||||
foreach ($requiredPath in $requiredPaths) {
|
||||
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
||||
throw "Required deployment file is missing: $requiredPath"
|
||||
}
|
||||
}
|
||||
Write-Detail 'server/ build context'
|
||||
Write-Detail 'docker-compose.yml'
|
||||
Write-Detail '.env.example'
|
||||
Write-Success 'Deployment payload is complete'
|
||||
Write-Host ''
|
||||
|
||||
Write-Step 'Packaging the release'
|
||||
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath
|
||||
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
|
||||
Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
|
||||
Write-Success 'Release archive is ready'
|
||||
Write-Host ''
|
||||
|
||||
# This template is single-quoted so PowerShell does not expand the remote
|
||||
# shell's variables. Replacement values are validated before insertion.
|
||||
$remoteCommand = @'
|
||||
set -eu
|
||||
|
||||
destination='__DESTINATION__'
|
||||
health_timeout=__HEALTH_TIMEOUT__
|
||||
parent=$(dirname "$destination")
|
||||
staging="${destination}.new.$$"
|
||||
backup="${destination}.previous.$$"
|
||||
activated=0
|
||||
previous_stopped=0
|
||||
|
||||
step() {
|
||||
printf '\033[36m ● %s\033[0m\n' "$1"
|
||||
}
|
||||
|
||||
detail() {
|
||||
printf '\033[90m ↳ %s\033[0m\n' "$1"
|
||||
}
|
||||
|
||||
success() {
|
||||
printf '\033[32m ✓ %s\033[0m\n' "$1"
|
||||
}
|
||||
|
||||
failure() {
|
||||
printf '\033[31m ✗ %s\033[0m\n' "$1" >&2
|
||||
}
|
||||
|
||||
rollback() {
|
||||
status=$?
|
||||
trap - EXIT
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
failure "Deployment failed; cleaning up"
|
||||
rm -rf -- "$staging"
|
||||
|
||||
if [ "$activated" -eq 1 ]; then
|
||||
if [ -f "$destination/docker-compose.yml" ]; then
|
||||
detail "Stopping the incomplete application release"
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose down --remove-orphans >/dev/null 2>&1
|
||||
) || true
|
||||
fi
|
||||
|
||||
rm -rf -- "$destination"
|
||||
if [ -e "$backup" ] || [ -L "$backup" ]; then
|
||||
detail "Restoring the previous application release"
|
||||
mv -- "$backup" "$destination"
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
||||
) || true
|
||||
failure "Previous application files were restored"
|
||||
fi
|
||||
elif [ "$previous_stopped" -eq 1 ] && [ -f "$destination/docker-compose.yml" ]; then
|
||||
detail "Restarting the previous application release"
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
||||
) || true
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
wait_for_service() {
|
||||
service="$1"
|
||||
elapsed=0
|
||||
detail "Waiting for $service"
|
||||
|
||||
while [ "$elapsed" -lt "$health_timeout" ]; do
|
||||
container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$container_id" ]; then
|
||||
state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
|
||||
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)
|
||||
|
||||
if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
|
||||
success "$service is $state ($health)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
|
||||
failure "$service entered state: $state"
|
||||
docker compose logs --no-color --tail 60 "$service" || true
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
failure "$service did not become healthy within ${health_timeout}s"
|
||||
docker compose logs --no-color --tail 60 "$service" || true
|
||||
return 1
|
||||
}
|
||||
|
||||
trap rollback EXIT
|
||||
trap 'exit 130' INT TERM
|
||||
|
||||
step '[remote 1/8] Checking Docker'
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
failure 'Docker is not installed on the NAS'
|
||||
exit 1
|
||||
fi
|
||||
docker info >/dev/null 2>&1 || {
|
||||
failure 'Docker is installed but the daemon is unavailable'
|
||||
exit 1
|
||||
}
|
||||
docker compose version >/dev/null 2>&1 || {
|
||||
failure 'The Docker Compose v2 plugin is not installed'
|
||||
exit 1
|
||||
}
|
||||
success "$(docker --version)"
|
||||
success "$(docker compose version)"
|
||||
|
||||
step '[remote 2/8] Extracting the release'
|
||||
# Checked before anything is created: the staging directory, the swap and the backup all
|
||||
# need write access to the parent, and "can't create directory" from BusyBox halfway
|
||||
# through a deployment is a poor way to learn the account cannot write there.
|
||||
mkdir -p -- "$parent" 2>/dev/null || true
|
||||
if [ ! -d "$parent" ]; then
|
||||
failure "$parent does not exist and could not be created by $(id -un)"
|
||||
detail 'Pick a path this account can write to with -Destination'
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -w "$parent" ]; then
|
||||
failure "$(id -un) cannot write to $parent"
|
||||
detail 'Deploy somewhere this account owns, for example:'
|
||||
detail ' .\deploy-server.ps1 -Destination /share/Docker/Memby'
|
||||
detail "or grant this account write access to $parent"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf -- "$staging"
|
||||
mkdir -- "$staging"
|
||||
tar -xf - -C "$staging"
|
||||
test -f "$staging/docker-compose.yml"
|
||||
test -f "$staging/server/Dockerfile"
|
||||
success 'Release extracted'
|
||||
|
||||
step '[remote 3/8] Installing configuration from .env.example'
|
||||
# The local .env.example is the single source of truth for configuration and carries
|
||||
# real values rather than placeholders. Every deployment overwrites the deployed .env
|
||||
# with it, so neither a Git push nor an SSH edit is needed.
|
||||
previous_password=''
|
||||
if [ -f "$destination/.env" ]; then
|
||||
previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||||
# Kept beside the new one purely so a bad edit is recoverable by hand.
|
||||
cp -- "$destination/.env" "$staging/.env.previous"
|
||||
detail 'Previous .env saved as .env.previous'
|
||||
fi
|
||||
|
||||
cp -- "$staging/.env.example" "$staging/.env"
|
||||
success 'Installed .env from .env.example'
|
||||
|
||||
new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
if [ -z "$new_password" ]; then
|
||||
failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$emby_url" ]; then
|
||||
failure 'MEMBY_EMBY_URL is empty in .env.example'
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$admin_token" ]; then
|
||||
failure 'MEMBY_ADMIN_TOKEN is empty in .env.example; refusing to deploy with /admin disabled'
|
||||
exit 1
|
||||
fi
|
||||
if [ "$configured_port" != '32768' ]; then
|
||||
failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})"
|
||||
exit 1
|
||||
fi
|
||||
success 'Required gateway configuration is present'
|
||||
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
|
||||
failure 'POSTGRES_PASSWORD differs from the deployed value'
|
||||
detail 'The PostgreSQL volume is always preserved; restore the deployed password in .env.example'
|
||||
detail 'Change the database credential separately with an explicit migration if required'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$staging"
|
||||
docker compose config --quiet
|
||||
)
|
||||
success 'Compose configuration is valid'
|
||||
|
||||
step '[remote 4/8] Pulling PostgreSQL and Redis'
|
||||
(
|
||||
cd "$staging"
|
||||
docker compose pull postgres redis
|
||||
)
|
||||
success 'Dependency images are ready'
|
||||
|
||||
step '[remote 5/8] Building memby-server'
|
||||
(
|
||||
cd "$staging"
|
||||
docker compose build --pull server
|
||||
)
|
||||
success 'Server image built'
|
||||
|
||||
step '[remote 6/8] Activating the release'
|
||||
rm -rf -- "$backup"
|
||||
if [ -e "$destination" ] || [ -L "$destination" ]; then
|
||||
if [ -f "$destination/docker-compose.yml" ]; then
|
||||
# Compose projects created by older releases may use a different project
|
||||
# name. Stop them from their original directory before moving it so their
|
||||
# published ports (especially 32768) are released for the new stack.
|
||||
detail 'Stopping the previous Compose application'
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose down --remove-orphans
|
||||
)
|
||||
previous_stopped=1
|
||||
success 'Previous Compose application stopped'
|
||||
fi
|
||||
|
||||
mv -- "$destination" "$backup"
|
||||
fi
|
||||
mv -- "$staging" "$destination"
|
||||
activated=1
|
||||
success 'Release activated'
|
||||
|
||||
step '[remote 7/8] Starting the Compose stack'
|
||||
cd "$destination"
|
||||
docker compose up -d --remove-orphans
|
||||
success 'Compose start command completed'
|
||||
|
||||
step '[remote 8/8] Waiting for healthy services'
|
||||
wait_for_service postgres
|
||||
wait_for_service redis
|
||||
wait_for_service server
|
||||
|
||||
published_address=$(docker compose port server 32768 | head -n 1)
|
||||
actual_port=${published_address##*:}
|
||||
if [ "$actual_port" != '32768' ]; then
|
||||
failure "Memby published the wrong NAS port: ${published_address:-none}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
server_container_id=$(docker compose ps -q server)
|
||||
if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$server_container_id" |
|
||||
grep -q '^MEMBY_ADMIN_TOKEN=.'; then
|
||||
failure 'MEMBY_ADMIN_TOKEN was not passed into the running server container'
|
||||
exit 1
|
||||
fi
|
||||
success 'Runtime configuration includes the admin token'
|
||||
|
||||
printf '\n'
|
||||
docker compose ps
|
||||
printf '\n'
|
||||
|
||||
rm -rf -- "$backup"
|
||||
activated=0
|
||||
trap - EXIT INT TERM
|
||||
success 'Remote deployment is healthy'
|
||||
success 'Memby gateway: https://mserver.sublogue.com'
|
||||
'@
|
||||
|
||||
$remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
|
||||
$remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
|
||||
# This file is edited on Windows, so the here-string above arrives with whatever line
|
||||
# endings it was saved with. A remote shell reads a trailing carriage return as part
|
||||
# of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
|
||||
# script is normalised to LF here rather than depending on how it was saved.
|
||||
$remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||
|
||||
Write-Step "Deploying to $RemoteHost"
|
||||
Write-Detail 'One SSH password prompt will appear'
|
||||
Write-Detail 'Remote build output follows'
|
||||
Write-Host ''
|
||||
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
|
||||
|
||||
$deploymentTimer.Stop()
|
||||
Write-Host ''
|
||||
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
|
||||
Write-Host ' Memby gateway: https://mserver.sublogue.com' -ForegroundColor White
|
||||
Write-Host " NAS endpoint: http://${RemoteHost}:32768" -ForegroundColor DarkGray
|
||||
Write-Host " Install path: ${RemoteHost}:$Destination" -ForegroundColor DarkGray
|
||||
}
|
||||
catch {
|
||||
$deploymentTimer.Stop()
|
||||
Write-Host ''
|
||||
Write-Host "✗ Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workDirectory) {
|
||||
Remove-Item -LiteralPath $workDirectory -Recurse -Force
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds, verifies, and installs a signed Memby release on an Android TV.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses the permanent signing configuration stored in the current Windows user's
|
||||
MEMBY_KEYSTORE environment variables. The APK is installed directly with ADB;
|
||||
no Gitea release or update server is required.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-tv.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-tv.ps1 -Device 10.0.0.238:35343 -SkipTests
|
||||
#>
|
||||
|
||||
#Requires -Version 7.2
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[ValidatePattern('^[A-Za-z0-9._:-]+$')]
|
||||
[string] $Device = '10.0.0.238:35343',
|
||||
|
||||
[Parameter()]
|
||||
[switch] $SkipTests
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = $PSScriptRoot
|
||||
$packageName = 'com.ponzischeme89.memby'
|
||||
|
||||
function Invoke-Checked {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $FilePath,
|
||||
|
||||
[Parameter()]
|
||||
[string[]] $Arguments = @()
|
||||
)
|
||||
|
||||
& $FilePath @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "'$FilePath' failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AndroidSdk {
|
||||
if ($env:ANDROID_HOME -and (Test-Path -LiteralPath $env:ANDROID_HOME)) {
|
||||
return $env:ANDROID_HOME
|
||||
}
|
||||
|
||||
$propertiesPath = Join-Path $root 'local.properties'
|
||||
if (Test-Path -LiteralPath $propertiesPath) {
|
||||
$sdkLine = Get-Content -LiteralPath $propertiesPath |
|
||||
Where-Object { $_ -match '^sdk\.dir=' } |
|
||||
Select-Object -First 1
|
||||
if ($sdkLine) {
|
||||
$sdkPath = $sdkLine.Substring($sdkLine.IndexOf('=') + 1).
|
||||
Replace('\:', ':').
|
||||
Replace('\\', '\')
|
||||
if (Test-Path -LiteralPath $sdkPath) {
|
||||
return $sdkPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw 'Android SDK not found. Set ANDROID_HOME or sdk.dir in local.properties.'
|
||||
}
|
||||
|
||||
function Import-UserSigningEnvironment {
|
||||
foreach ($name in @(
|
||||
'MEMBY_KEYSTORE',
|
||||
'MEMBY_KEYSTORE_PASSWORD',
|
||||
'MEMBY_KEY_ALIAS',
|
||||
'MEMBY_KEY_PASSWORD'
|
||||
)) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name, 'User')
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
throw "Release signing variable $name is not configured."
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $env:MEMBY_KEYSTORE -PathType Leaf)) {
|
||||
throw "Release keystore not found: $env:MEMBY_KEYSTORE"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '╭─ Memby TV deployment' -ForegroundColor Magenta
|
||||
Write-Host "│ Device $Device" -ForegroundColor DarkGray
|
||||
Write-Host '│ Build signed release' -ForegroundColor DarkGray
|
||||
Write-Host '╰─' -ForegroundColor Magenta
|
||||
Write-Host ''
|
||||
|
||||
Import-UserSigningEnvironment
|
||||
$sdk = Get-AndroidSdk
|
||||
$adb = Join-Path $sdk 'platform-tools\adb.exe'
|
||||
if (-not (Test-Path -LiteralPath $adb -PathType Leaf)) {
|
||||
throw "ADB not found: $adb"
|
||||
}
|
||||
|
||||
$buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory |
|
||||
Sort-Object { [version]$_.Name } -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $buildTools) {
|
||||
throw 'Android SDK Build Tools are not installed.'
|
||||
}
|
||||
$apkSigner = Join-Path $buildTools.FullName 'apksigner.bat'
|
||||
if (-not (Test-Path -LiteralPath $apkSigner -PathType Leaf)) {
|
||||
throw "APK signer not found: $apkSigner"
|
||||
}
|
||||
|
||||
$jdkHome = 'C:\Program Files\Android\Android Studio\jbr'
|
||||
if (Test-Path -LiteralPath $jdkHome) {
|
||||
$env:JAVA_HOME = $jdkHome
|
||||
}
|
||||
|
||||
Write-Host '● Building release APK' -ForegroundColor Cyan
|
||||
$gradleArguments = @('--console=plain')
|
||||
if (-not $SkipTests) {
|
||||
$gradleArguments += 'testDebugUnitTest'
|
||||
}
|
||||
$gradleArguments += 'assembleRelease'
|
||||
Invoke-Checked -FilePath (Join-Path $root 'gradlew.bat') -Arguments $gradleArguments
|
||||
|
||||
$apk = Join-Path $root 'app\build\outputs\apk\release\app-release.apk'
|
||||
if (-not (Test-Path -LiteralPath $apk -PathType Leaf)) {
|
||||
$unsigned = Join-Path $root 'app\build\outputs\apk\release\app-release-unsigned.apk'
|
||||
if (Test-Path -LiteralPath $unsigned) {
|
||||
throw 'Gradle produced an unsigned APK; deployment was stopped.'
|
||||
}
|
||||
throw "Release APK not found: $apk"
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '● Verifying release signature' -ForegroundColor Cyan
|
||||
Invoke-Checked -FilePath $apkSigner -Arguments @(
|
||||
'verify',
|
||||
'--verbose',
|
||||
'--print-certs',
|
||||
$apk
|
||||
)
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '● Connecting to Android TV' -ForegroundColor Cyan
|
||||
& $adb connect $Device
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to connect to Android TV at $Device."
|
||||
}
|
||||
Invoke-Checked -FilePath $adb -Arguments @('-s', $Device, 'get-state')
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '● Installing release' -ForegroundColor Cyan
|
||||
Invoke-Checked -FilePath $adb -Arguments @(
|
||||
'-s', $Device,
|
||||
'install',
|
||||
'-r',
|
||||
'--no-streaming',
|
||||
$apk
|
||||
)
|
||||
|
||||
$packageDetails = & $adb -s $Device shell dumpsys package $packageName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'The APK installed, but its package details could not be read.'
|
||||
}
|
||||
$versionName = (
|
||||
$packageDetails |
|
||||
Select-String -Pattern 'versionName=([^\s]+)' |
|
||||
Select-Object -First 1
|
||||
).Matches.Groups[1].Value
|
||||
if ([string]::IsNullOrWhiteSpace($versionName)) {
|
||||
throw "Installed package $packageName was not found."
|
||||
}
|
||||
|
||||
$resolveOutput = & $adb -s $Device shell cmd package resolve-activity --brief `
|
||||
-a android.intent.action.MAIN `
|
||||
-c android.intent.category.LEANBACK_LAUNCHER `
|
||||
$packageName
|
||||
$component = $resolveOutput |
|
||||
Where-Object { $_.Trim() -match '^[A-Za-z0-9._]+/[A-Za-z0-9._$]+$' } |
|
||||
Select-Object -Last 1
|
||||
if ($LASTEXITCODE -eq 0 -and $component) {
|
||||
$component = $component.Trim()
|
||||
Invoke-Checked -FilePath $adb -Arguments @(
|
||||
'-s', $Device,
|
||||
'shell', 'am', 'start',
|
||||
'-n', $component
|
||||
)
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "✓ Memby $versionName installed on $Device" -ForegroundColor Green
|
||||
Write-Host " Package: $packageName" -ForegroundColor DarkGray
|
||||
Write-Host " APK: $apk" -ForegroundColor DarkGray
|
||||
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kinds are part of the wire contract: an unknown kind is rendered with the generic
|
||||
// banner rather than dropped, so adding one never needs an app release.
|
||||
const (
|
||||
alertKindSonarrAired = "sonarr-aired"
|
||||
|
||||
// A TV shows one banner at a time; more than a few queued up is noise, not news.
|
||||
maxAlerts = 3
|
||||
)
|
||||
|
||||
// clientAlert is a short-lived, informational nudge delivered on the /v1/status poll —
|
||||
// the only channel the app already listens to while it is open. It carries no action:
|
||||
// the client slides it in, shows it for a few seconds and forgets it.
|
||||
type clientAlert struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
ItemID string `json:"itemId,omitempty"`
|
||||
ImageTag string `json:"imageTag,omitempty"`
|
||||
AiredAt string `json:"airedAt,omitempty"`
|
||||
}
|
||||
|
||||
// sonarrAiredAlerts reads the day's calendar through the same cache the airing-today row
|
||||
// uses, so polling clients never cost a Sonarr request of their own.
|
||||
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
||||
if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 {
|
||||
return nil
|
||||
}
|
||||
row, err := s.sonarrAiringTodayRow(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("sonarr alerts unavailable", "error", err)
|
||||
return nil
|
||||
}
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
items := make([]sonarrScheduleItem, 0, len(row.Items))
|
||||
for _, raw := range row.Items {
|
||||
var item sonarrScheduleItem
|
||||
if json.Unmarshal(raw, &item) != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow)
|
||||
}
|
||||
|
||||
// buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the
|
||||
// gap the viewer would otherwise experience as "it's Tuesday, so why isn't it there".
|
||||
// Anything already downloaded is deliberately silent: it is on the home screen, which
|
||||
// says it better than a banner would.
|
||||
func buildSonarrAlerts(items []sonarrScheduleItem, now time.Time, window time.Duration) []clientAlert {
|
||||
if window <= 0 {
|
||||
return nil
|
||||
}
|
||||
type dated struct {
|
||||
alert clientAlert
|
||||
airs time.Time
|
||||
}
|
||||
found := make([]dated, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.MembyAirsAt == "" {
|
||||
continue
|
||||
}
|
||||
airsAt, err := time.Parse(time.RFC3339, item.MembyAirsAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Not yet aired, or aired so long ago that saying so is no longer news.
|
||||
if airsAt.After(now) || now.Sub(airsAt) > window {
|
||||
continue
|
||||
}
|
||||
message, ok := airedMessage(item, airsAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found = append(found, dated{
|
||||
alert: clientAlert{
|
||||
ID: item.ID + ":aired",
|
||||
Kind: alertKindSonarrAired,
|
||||
Title: item.Name,
|
||||
Message: message,
|
||||
ItemID: item.ID,
|
||||
ImageTag: item.ImageTags["Primary"],
|
||||
AiredAt: item.MembyAirsAt,
|
||||
},
|
||||
airs: airsAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Newest first: if several aired inside the window, the most recent is the one the
|
||||
// viewer is most likely to be waiting on.
|
||||
sort.SliceStable(found, func(i, j int) bool { return found[i].airs.After(found[j].airs) })
|
||||
if len(found) > maxAlerts {
|
||||
found = found[:maxAlerts]
|
||||
}
|
||||
alerts := make([]clientAlert, 0, len(found))
|
||||
for _, entry := range found {
|
||||
alerts = append(alerts, entry.alert)
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func airedMessage(item sonarrScheduleItem, airsAt time.Time) (string, bool) {
|
||||
episode := item.MembyEpisodeCode
|
||||
if item.MembyEpisodeTitle != "" {
|
||||
episode = fmt.Sprintf("%s — %s", episode, item.MembyEpisodeTitle)
|
||||
}
|
||||
switch item.MembyAvailability {
|
||||
case "downloading":
|
||||
return fmt.Sprintf("%s aired at %s and is downloading now.", episode, airsAt.Format("3:04 PM")), true
|
||||
case "awaiting":
|
||||
return fmt.Sprintf("%s aired at %s and will be in Emby soon.", episode, airsAt.Format("3:04 PM")), true
|
||||
default:
|
||||
// available (already watchable) and unmonitored (never coming) both have
|
||||
// nothing useful to announce.
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func airedItem(id, name, availability string, airsAt time.Time) sonarrScheduleItem {
|
||||
return sonarrScheduleItem{
|
||||
ID: id,
|
||||
Name: name,
|
||||
MembyEpisodeCode: "S02E04",
|
||||
MembyEpisodeTitle: "The Crossing",
|
||||
MembyAirsAt: airsAt.Format(time.RFC3339),
|
||||
MembyAvailability: availability,
|
||||
MembyAvailabilityText: availability,
|
||||
ImageTags: map[string]string{"Primary": "sonarr"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSonarrAlertsAnnouncesOnlyEpisodesNotYetInEmby(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 27, 21, 30, 0, 0, location)
|
||||
aired := now.Add(-30 * time.Minute)
|
||||
|
||||
alerts := buildSonarrAlerts([]sonarrScheduleItem{
|
||||
airedItem("sonarr:7:42", "Northbound", "awaiting", aired),
|
||||
airedItem("sonarr:8:43", "Grabbed Show", "downloading", aired.Add(-time.Minute)),
|
||||
airedItem("sonarr:9:44", "Already Here", "available", aired),
|
||||
airedItem("sonarr:10:45", "Not Watched", "unmonitored", aired),
|
||||
airedItem("sonarr:11:46", "Later Tonight", "upcoming", now.Add(90*time.Minute)),
|
||||
airedItem("sonarr:12:47", "This Morning", "awaiting", now.Add(-8*time.Hour)),
|
||||
}, now, 3*time.Hour)
|
||||
|
||||
if len(alerts) != 2 {
|
||||
t.Fatalf("expected 2 alerts, got %d: %+v", len(alerts), alerts)
|
||||
}
|
||||
first := alerts[0]
|
||||
if first.ID != "sonarr:7:42:aired" || first.Kind != alertKindSonarrAired {
|
||||
t.Fatalf("unexpected alert identity: %+v", first)
|
||||
}
|
||||
if first.Title != "Northbound" || first.ItemID != "sonarr:7:42" || first.ImageTag != "sonarr" {
|
||||
t.Fatalf("unexpected alert payload: %+v", first)
|
||||
}
|
||||
if !strings.Contains(first.Message, "S02E04 — The Crossing aired at 9:00 PM") ||
|
||||
!strings.Contains(first.Message, "in Emby soon") {
|
||||
t.Fatalf("unexpected awaiting message: %q", first.Message)
|
||||
}
|
||||
if !strings.Contains(alerts[1].Message, "downloading now") {
|
||||
t.Fatalf("unexpected downloading message: %q", alerts[1].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSonarrAlertsSortsNewestFirstAndCaps(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 27, 22, 0, 0, 0, location)
|
||||
|
||||
items := []sonarrScheduleItem{
|
||||
airedItem("sonarr:1:1", "Oldest", "awaiting", now.Add(-100*time.Minute)),
|
||||
airedItem("sonarr:2:2", "Newest", "awaiting", now.Add(-5*time.Minute)),
|
||||
airedItem("sonarr:3:3", "Middle", "awaiting", now.Add(-40*time.Minute)),
|
||||
airedItem("sonarr:4:4", "Older", "awaiting", now.Add(-80*time.Minute)),
|
||||
}
|
||||
alerts := buildSonarrAlerts(items, now, 3*time.Hour)
|
||||
if len(alerts) != maxAlerts {
|
||||
t.Fatalf("expected the list capped at %d, got %d", maxAlerts, len(alerts))
|
||||
}
|
||||
if alerts[0].Title != "Newest" || alerts[1].Title != "Middle" || alerts[2].Title != "Older" {
|
||||
t.Fatalf("alerts are not newest-first: %+v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSonarrAlertsDisabledByZeroWindow(t *testing.T) {
|
||||
now := time.Date(2026, 7, 27, 22, 0, 0, 0, time.UTC)
|
||||
items := []sonarrScheduleItem{airedItem("sonarr:1:1", "Northbound", "awaiting", now.Add(-time.Minute))}
|
||||
if alerts := buildSonarrAlerts(items, now, 0); alerts != nil {
|
||||
t.Fatalf("a zero window must disable alerts, got %+v", alerts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetailFieldsIncludeEmbyPeople(t *testing.T) {
|
||||
fields := strings.Split(fieldsDetail, ",")
|
||||
for _, field := range fields {
|
||||
if field == "People" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("detail responses must request Emby's People field for cast metadata and portrait tags")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpectedClientDisconnect(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary", nil)
|
||||
for _, err := range []error{
|
||||
context.Canceled,
|
||||
syscall.EPIPE,
|
||||
syscall.ECONNRESET,
|
||||
errors.New("write tcp: broken pipe"),
|
||||
errors.New("client disconnected"),
|
||||
} {
|
||||
if !expectedClientDisconnect(req, err) {
|
||||
t.Errorf("%q should be an expected client disconnect", err)
|
||||
}
|
||||
}
|
||||
if expectedClientDisconnect(req, io.ErrUnexpectedEOF) {
|
||||
t.Fatal("an upstream truncated image must remain a real warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectedClientDisconnectUsesRequestContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary", nil).WithContext(ctx)
|
||||
if !expectedClientDisconnect(req, errors.New("opaque response writer error")) {
|
||||
t.Fatal("a cancelled request should be treated as viewer cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaggedImageConditionalRequestSkipsUpstream(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary?tag=abc123", nil)
|
||||
req.Header.Set("If-None-Match", `"abc123"`)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if !writeNotModifiedForTag(rec, req, "abc123") {
|
||||
t.Fatal("matching image tag should short-circuit")
|
||||
}
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Fatalf("status = %d, want 304", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("ETag"); got != `"abc123"` {
|
||||
t.Fatalf("etag = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
status int
|
||||
want slog.Level
|
||||
}{
|
||||
{"/healthz", http.StatusOK, slog.LevelDebug},
|
||||
{"/v1/status", http.StatusOK, slog.LevelDebug},
|
||||
{"/v1/images/123/primary", http.StatusOK, slog.LevelDebug},
|
||||
{"/v1/home", http.StatusOK, slog.LevelInfo},
|
||||
{"/v1/images/123/primary", http.StatusNotFound, slog.LevelWarn},
|
||||
{"/v1/home", http.StatusServiceUnavailable, slog.LevelError},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := requestLogLevel(test.path, test.status); got != test.want {
|
||||
t.Errorf("requestLogLevel(%q, %d) = %v, want %v", test.path, test.status, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientLogValueIsNeverBlank(t *testing.T) {
|
||||
if got := clientLogValue(""); got != "unknown" {
|
||||
t.Fatalf("blank identity logged as %q", got)
|
||||
}
|
||||
if got := clientLogValue("0.1.60"); got != "0.1.60" {
|
||||
t.Fatalf("reported identity changed to %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubtitleMIME(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"srt": "application/x-subrip",
|
||||
"subrip": "application/x-subrip",
|
||||
"webvtt": "text/vtt",
|
||||
"ass": "text/x-ssa",
|
||||
"mov_text": "application/x-quicktime-tx3g",
|
||||
"pgssub": "",
|
||||
}
|
||||
for codec, want := range tests {
|
||||
if got := subtitleMIME(codec, ""); got != want {
|
||||
t.Errorf("subtitleMIME(%q) = %q, want %q", codec, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleMIMEFallsBackToDeliveryExtension(t *testing.T) {
|
||||
if got := subtitleMIME("", "https://emby.example/subtitles/4/stream.vtt?api_key=x"); got != "text/vtt" {
|
||||
t.Fatalf("subtitleMIME delivery fallback = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
)
|
||||
|
||||
const maxReleaseSize = 250 << 20
|
||||
|
||||
var (
|
||||
releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
releaseFilenamePattern = regexp.MustCompile(`^memby-\d+\.\d+\.\d+\.apk$`)
|
||||
)
|
||||
|
||||
// releasePublishAuth is deliberately separate from adminAuth: CI can publish an APK but
|
||||
// cannot take the service offline, force an update, or read household analytics.
|
||||
func (s *Server) releasePublishAuth(h http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.ReleasePublishToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.ReleasePublishToken)) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid release token")
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleReleasePublish accepts the signed APK produced by Gitea Actions, persists it,
|
||||
// and atomically makes it the version offered to TVs.
|
||||
func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxReleaseSize)
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid release upload")
|
||||
return
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(r.FormValue("version"))
|
||||
if !releaseVersionPattern.MatchString(version) {
|
||||
writeError(w, http.StatusBadRequest, "version must look like 0.1.54")
|
||||
return
|
||||
}
|
||||
|
||||
current := s.updatePolicy.get()
|
||||
if current.LatestVersion != "" &&
|
||||
appupdate.CompareVersions(version, current.LatestVersion) < 0 {
|
||||
writeError(w, http.StatusConflict, "refusing to publish an older version")
|
||||
return
|
||||
}
|
||||
|
||||
source, _, err := r.FormFile("apk")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "signed APK is required")
|
||||
return
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
if err := os.MkdirAll(s.cfg.ReleaseDir, 0o750); err != nil {
|
||||
s.log.Error("release directory unavailable", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "release storage unavailable")
|
||||
return
|
||||
}
|
||||
temp, err := os.CreateTemp(s.cfg.ReleaseDir, ".memby-upload-*")
|
||||
if err != nil {
|
||||
s.log.Error("release temp file failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "release storage unavailable")
|
||||
return
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer os.Remove(tempName)
|
||||
|
||||
written, copyErr := io.Copy(temp, source)
|
||||
closeErr := temp.Close()
|
||||
if copyErr != nil || closeErr != nil || written < 4 {
|
||||
writeError(w, http.StatusBadRequest, "could not store APK")
|
||||
return
|
||||
}
|
||||
|
||||
// APKs are ZIP archives. This catches accidentally uploaded logs or HTML error pages
|
||||
// before they become an update every TV is invited to install.
|
||||
stored, err := os.Open(tempName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not verify APK")
|
||||
return
|
||||
}
|
||||
var magic [4]byte
|
||||
_, readErr := io.ReadFull(stored, magic[:])
|
||||
stored.Close()
|
||||
if readErr != nil || string(magic[:2]) != "PK" {
|
||||
writeError(w, http.StatusBadRequest, "uploaded file is not an APK")
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("memby-%s.apk", version)
|
||||
destination := filepath.Join(s.cfg.ReleaseDir, filename)
|
||||
if err := os.Rename(tempName, destination); err != nil {
|
||||
s.log.Error("release publish rename failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not publish APK")
|
||||
return
|
||||
}
|
||||
if err := os.Chmod(destination, 0o640); err != nil {
|
||||
s.log.Warn("release permissions could not be tightened", "error", err)
|
||||
}
|
||||
|
||||
policy := appupdate.Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: version,
|
||||
MinimumVersion: current.MinimumVersion,
|
||||
DownloadURL: s.cfg.PublicURL + "/updates/" + filename,
|
||||
Notes: strings.TrimSpace(r.FormValue("notes")),
|
||||
}
|
||||
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
|
||||
s.log.Error("release policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved")
|
||||
return
|
||||
}
|
||||
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
|
||||
s.log.Error("release policy reload failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded")
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Info("release published", "version", version, "bytes", written, "file", filename)
|
||||
writeJSON(w, http.StatusCreated, s.updatePolicy.get())
|
||||
}
|
||||
|
||||
// handleReleaseDownload serves immutable, signed APKs. They carry no household secrets,
|
||||
// so downloads do not need a TV session and continue working through Android's installer.
|
||||
func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if !releaseFilenamePattern.MatchString(filename) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
)
|
||||
|
||||
func TestReleasePublishAuth(t *testing.T) {
|
||||
handler := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
||||
|
||||
t.Run("disabled is hidden", func(t *testing.T) {
|
||||
s := &Server{cfg: config.Config{}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.releasePublishAuth(handler).ServeHTTP(
|
||||
rec,
|
||||
httptest.NewRequest(http.MethodPost, "/admin/api/release", nil),
|
||||
)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid bearer token is accepted", func(t *testing.T) {
|
||||
s := &Server{cfg: config.Config{ReleasePublishToken: "release-secret"}}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/release", nil)
|
||||
req.Header.Set("Authorization", "Bearer release-secret")
|
||||
s.releasePublishAuth(handler).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d, want 204", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
payload := []byte("PK signed apk")
|
||||
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.54.apk"), payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{cfg: config.Config{ReleaseDir: dir}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.54.apk", nil)
|
||||
req.SetPathValue("filename", "memby-0.1.54.apk")
|
||||
s.handleReleaseDownload(rec, req)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != string(payload) {
|
||||
t.Fatalf("valid release response = %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/updates/../secrets", nil)
|
||||
req.SetPathValue("filename", "../secrets")
|
||||
s.handleReleaseDownload(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("invalid filename got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const sonarrCalendarCachePrefix = "sonarr:calendar:"
|
||||
const sonarrPrerollCachePrefix = "sonarr:preroll:"
|
||||
|
||||
type prerollScheduleResponse struct {
|
||||
Today []prerollScheduleEntry `json:"today"`
|
||||
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
|
||||
}
|
||||
|
||||
type prerollScheduleEntry struct {
|
||||
Series string `json:"series"`
|
||||
Episode string `json:"episode"`
|
||||
EpisodeCode string `json:"episodeCode"`
|
||||
Schedule string `json:"schedule"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
if s.sonarr == nil {
|
||||
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
|
||||
return
|
||||
}
|
||||
schedule, err := s.sonarrPrerollSchedule(r.Context())
|
||||
if err != nil {
|
||||
// Pre-roll is decorative and must never become a playback dependency.
|
||||
s.log.Warn("preroll schedule unavailable", "error", err)
|
||||
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, schedule)
|
||||
}
|
||||
|
||||
func (s *Server) sonarrPrerollSchedule(ctx context.Context) (prerollScheduleResponse, error) {
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
|
||||
key := sonarrPrerollCachePrefix + dayStart.Format("2006-01-02")
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var cached prerollScheduleResponse
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
return normalizePrerollSchedule(cached), nil
|
||||
}
|
||||
}
|
||||
|
||||
s.sonarrMu.Lock()
|
||||
defer s.sonarrMu.Unlock()
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var cached prerollScheduleResponse
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
return normalizePrerollSchedule(cached), nil
|
||||
}
|
||||
}
|
||||
|
||||
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 7))
|
||||
if err != nil {
|
||||
return emptyPrerollSchedule(), err
|
||||
}
|
||||
schedule := buildPrerollSchedule(episodes, now, location)
|
||||
if raw, err := json.Marshal(schedule); err == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, raw, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.log.Warn("preroll schedule cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return schedule, nil
|
||||
}
|
||||
|
||||
func buildPrerollSchedule(
|
||||
episodes []sonarr.Episode,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
) prerollScheduleResponse {
|
||||
sort.SliceStable(episodes, func(i, j int) bool {
|
||||
if episodes[i].AirDateUTC == nil {
|
||||
return false
|
||||
}
|
||||
if episodes[j].AirDateUTC == nil {
|
||||
return true
|
||||
}
|
||||
return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
|
||||
})
|
||||
todayEnd := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
|
||||
result := emptyPrerollSchedule()
|
||||
for _, episode := range episodes {
|
||||
if episode.AirDateUTC == nil {
|
||||
continue
|
||||
}
|
||||
airTime := episode.AirDateUTC.In(location)
|
||||
entry := prerollScheduleEntry{
|
||||
Series: episode.Series.Title,
|
||||
Episode: episode.Title,
|
||||
EpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
|
||||
Availability: prerollAvailability(episode),
|
||||
}
|
||||
if airTime.Before(todayEnd) {
|
||||
entry.Schedule = airTime.Format("3:04 PM")
|
||||
if len(result.Today) < 4 {
|
||||
result.Today = append(result.Today, entry)
|
||||
}
|
||||
} else {
|
||||
entry.Schedule = airTime.Format("Monday · 3:04 PM")
|
||||
if len(result.ThisWeek) < 6 {
|
||||
result.ThisWeek = append(result.ThisWeek, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func prerollAvailability(episode sonarr.Episode) string {
|
||||
switch {
|
||||
case episode.HasFile:
|
||||
return "Downloaded"
|
||||
case episode.Grabbed:
|
||||
return "Downloading"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func emptyPrerollSchedule() prerollScheduleResponse {
|
||||
return prerollScheduleResponse{
|
||||
Today: []prerollScheduleEntry{},
|
||||
ThisWeek: []prerollScheduleEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePrerollSchedule(value prerollScheduleResponse) prerollScheduleResponse {
|
||||
if value.Today == nil {
|
||||
value.Today = []prerollScheduleEntry{}
|
||||
}
|
||||
if value.ThisWeek == nil {
|
||||
value.ThisWeek = []prerollScheduleEntry{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// sonarrScheduleItem deliberately looks enough like an Emby item to reuse the fast home
|
||||
// card renderer, while its Memby fields mark it as informational and non-playable.
|
||||
type sonarrScheduleItem struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Overview string `json:"Overview,omitempty"`
|
||||
ProductionYear int `json:"ProductionYear,omitempty"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
Genres []string `json:"Genres"`
|
||||
ImageTags map[string]string `json:"ImageTags"`
|
||||
BackdropImageTags []string `json:"BackdropImageTags"`
|
||||
MembySource string `json:"MembySource"`
|
||||
MembyEpisodeTitle string `json:"MembyEpisodeTitle"`
|
||||
MembyEpisodeCode string `json:"MembyEpisodeCode"`
|
||||
MembyAirsAt string `json:"MembyAirsAt,omitempty"`
|
||||
MembyAddedAt string `json:"MembyAddedAt,omitempty"`
|
||||
MembyAirLabel string `json:"MembyAirLabel"`
|
||||
MembyAvailability string `json:"MembyAvailability"`
|
||||
MembyAvailabilityText string `json:"MembyAvailabilityText"`
|
||||
MembyPlayable bool `json:"MembyPlayable"`
|
||||
}
|
||||
|
||||
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
|
||||
if s.sonarr == nil {
|
||||
return nil, nil
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
|
||||
cacheKey := sonarrCalendarCachePrefix + dayStart.Format("2006-01-02")
|
||||
|
||||
if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// A shared lock prevents several users opening the app together from stampeding
|
||||
// Sonarr on the one cache miss each day.
|
||||
s.sonarrMu.Lock()
|
||||
defer s.sonarrMu.Unlock()
|
||||
if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := buildSonarrRow(episodes, now, location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(row)
|
||||
if err == nil {
|
||||
if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.log.Warn("sonarr calendar cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *Server) cachedSonarrRow(ctx context.Context, key string) *recommend.Row {
|
||||
raw, err := s.cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var row recommend.Row
|
||||
if json.Unmarshal(raw, &row) != nil {
|
||||
return nil
|
||||
}
|
||||
return &row
|
||||
}
|
||||
|
||||
func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Location) (*recommend.Row, error) {
|
||||
sort.SliceStable(episodes, func(i, j int) bool {
|
||||
if episodes[i].AirDateUTC == nil {
|
||||
return false
|
||||
}
|
||||
if episodes[j].AirDateUTC == nil {
|
||||
return true
|
||||
}
|
||||
return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
|
||||
})
|
||||
|
||||
items := make([]json.RawMessage, 0, len(episodes))
|
||||
for _, episode := range episodes {
|
||||
item := toSonarrScheduleItem(episode, now, location)
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, raw)
|
||||
}
|
||||
return &recommend.Row{
|
||||
ID: "sonarr-airing-today",
|
||||
Title: "Shows airing today",
|
||||
Kind: "schedule",
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.Location) sonarrScheduleItem {
|
||||
seriesID := episode.SeriesID
|
||||
if episode.Series.ID > 0 {
|
||||
seriesID = episode.Series.ID
|
||||
}
|
||||
item := sonarrScheduleItem{
|
||||
ID: fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID),
|
||||
Name: episode.Series.Title,
|
||||
Type: "MembySonarrEpisode",
|
||||
Overview: episode.Overview,
|
||||
ProductionYear: episode.Series.Year,
|
||||
RunTimeTicks: int64(episode.Runtime) * 600_000_000,
|
||||
Genres: nonNilStrings(episode.Series.Genres),
|
||||
ImageTags: map[string]string{},
|
||||
BackdropImageTags: []string{},
|
||||
MembySource: "sonarr",
|
||||
MembyEpisodeTitle: episode.Title,
|
||||
MembyEpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
|
||||
MembyPlayable: false,
|
||||
}
|
||||
if hasCover(episode.Series.Images, "poster") {
|
||||
item.ImageTags["Primary"] = "sonarr"
|
||||
}
|
||||
if hasCover(episode.Series.Images, "fanart") {
|
||||
item.BackdropImageTags = []string{"sonarr"}
|
||||
}
|
||||
|
||||
if episode.AirDateUTC != nil {
|
||||
airTime := episode.AirDateUTC.In(location)
|
||||
item.MembyAirsAt = airTime.Format(time.RFC3339)
|
||||
if airTime.After(now) {
|
||||
item.MembyAirLabel = "Airs today at " + airTime.Format("3:04 PM")
|
||||
} else {
|
||||
item.MembyAirLabel = "Aired today at " + airTime.Format("3:04 PM")
|
||||
}
|
||||
} else {
|
||||
item.MembyAirLabel = "Airs today"
|
||||
}
|
||||
|
||||
addedAt := episodeFileAddedAt(episode)
|
||||
switch {
|
||||
case episode.HasFile:
|
||||
item.MembyAvailability = "available"
|
||||
item.MembyAvailabilityText = "Downloaded"
|
||||
if addedAt != nil {
|
||||
localAdded := addedAt.In(location)
|
||||
item.MembyAddedAt = localAdded.Format(time.RFC3339)
|
||||
item.MembyAvailabilityText = "Added at " + localAdded.Format("3:04 PM")
|
||||
}
|
||||
case episode.Grabbed:
|
||||
item.MembyAvailability = "downloading"
|
||||
item.MembyAvailabilityText = "Downloading"
|
||||
case !episode.Monitored:
|
||||
item.MembyAvailability = "unmonitored"
|
||||
item.MembyAvailabilityText = "Not monitored"
|
||||
case episode.AirDateUTC != nil && episode.AirDateUTC.Before(now):
|
||||
item.MembyAvailability = "awaiting"
|
||||
item.MembyAvailabilityText = "Awaiting download"
|
||||
default:
|
||||
item.MembyAvailability = "upcoming"
|
||||
item.MembyAvailabilityText = "Upcoming"
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func episodeFileAddedAt(episode sonarr.Episode) *time.Time {
|
||||
if episode.EpisodeFile == nil {
|
||||
return nil
|
||||
}
|
||||
return episode.EpisodeFile.DateAdded
|
||||
}
|
||||
|
||||
func hasCover(images []sonarr.Image, coverType string) bool {
|
||||
for _, image := range images {
|
||||
if strings.EqualFold(image.CoverType, coverType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nonNilStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return []string{}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
)
|
||||
|
||||
func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
air := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC)
|
||||
added := time.Date(2026, 7, 27, 8, 12, 0, 0, time.UTC)
|
||||
row, err := buildSonarrRow([]sonarr.Episode{{
|
||||
ID: 42,
|
||||
SeriesID: 7,
|
||||
SeasonNumber: 2,
|
||||
EpisodeNumber: 4,
|
||||
Title: "The Crossing",
|
||||
AirDateUTC: &air,
|
||||
HasFile: true,
|
||||
Monitored: true,
|
||||
EpisodeFile: &sonarr.EpisodeFile{DateAdded: &added},
|
||||
Series: sonarr.Series{
|
||||
ID: 7,
|
||||
Title: "Northbound",
|
||||
Images: []sonarr.Image{
|
||||
{CoverType: "poster"},
|
||||
{CoverType: "fanart"},
|
||||
},
|
||||
},
|
||||
}}, time.Date(2026, 7, 27, 21, 0, 0, 0, location), location)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.ID != "sonarr-airing-today" || row.Kind != "schedule" || len(row.Items) != 1 {
|
||||
t.Fatalf("unexpected row: %+v", row)
|
||||
}
|
||||
var item sonarrScheduleItem
|
||||
if err := json.Unmarshal(row.Items[0], &item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.ID != "sonarr:7:42" || item.MembyEpisodeCode != "S02E04" {
|
||||
t.Fatalf("unexpected identity: %+v", item)
|
||||
}
|
||||
if item.MembyAvailability != "available" || item.MembyAvailabilityText != "Added at 8:12 PM" {
|
||||
t.Fatalf("unexpected availability: %+v", item)
|
||||
}
|
||||
if item.ImageTags["Primary"] == "" || len(item.BackdropImageTags) != 1 {
|
||||
t.Fatalf("artwork was not exposed: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 28, 10, 0, 0, 0, location)
|
||||
today := time.Date(2026, 7, 28, 20, 30, 0, 0, location).UTC()
|
||||
thursday := time.Date(2026, 7, 30, 19, 0, 0, 0, location).UTC()
|
||||
schedule := buildPrerollSchedule([]sonarr.Episode{
|
||||
{
|
||||
SeasonNumber: 1, EpisodeNumber: 4, Title: "Tonight",
|
||||
AirDateUTC: &today, Series: sonarr.Series{Title: "Northbound"},
|
||||
},
|
||||
{
|
||||
SeasonNumber: 2, EpisodeNumber: 1, Title: "Later",
|
||||
AirDateUTC: &thursday, HasFile: true, Series: sonarr.Series{Title: "Harbour"},
|
||||
},
|
||||
}, now, location)
|
||||
|
||||
if len(schedule.Today) != 1 || schedule.Today[0].Series != "Northbound" ||
|
||||
schedule.Today[0].Schedule != "8:30 PM" {
|
||||
t.Fatalf("unexpected today schedule: %+v", schedule.Today)
|
||||
}
|
||||
if len(schedule.ThisWeek) != 1 || schedule.ThisWeek[0].EpisodeCode != "S02E01" ||
|
||||
schedule.ThisWeek[0].Availability != "Downloaded" {
|
||||
t.Fatalf("unexpected week schedule: %+v", schedule.ThisWeek)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultClientAllowanceIsOne(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_MAX_CLIENTS_PER_USER", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.MaxClientsPerUser != 1 {
|
||||
t.Fatalf("MaxClientsPerUser = %d, want 1", cfg.MaxClientsPerUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_TRACEARR_URL", "http://tracearr")
|
||||
t.Setenv("MEMBY_TRACEARR_API_KEY", "")
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected incomplete Tracearr configuration to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package emby
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubtitleURLUsesCanonicalVTTEndpoint(t *testing.T) {
|
||||
client := New("http://emby:8096", "https://emby.example", "Memby", time.Second)
|
||||
got := client.SubtitleURL(
|
||||
Credentials{Token: "a b"},
|
||||
"item id",
|
||||
"source/id",
|
||||
4,
|
||||
)
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.EscapedPath() != "/Videos/item%20id/source%2Fid/Subtitles/4/Stream.vtt" {
|
||||
t.Fatalf("unexpected subtitle path %q", parsed.EscapedPath())
|
||||
}
|
||||
if parsed.Query().Get("api_key") != "a b" {
|
||||
t.Fatalf("subtitle token was not preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
// Package foryou keeps Tracearr-derived recommendation data warm in PostgreSQL.
|
||||
//
|
||||
// Imports and ranking happen away from television requests. PostgreSQL is also the
|
||||
// queue: dirty_since records work that still needs doing, so a restart cannot lose it.
|
||||
package foryou
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||
)
|
||||
|
||||
const (
|
||||
importPageSize = 100
|
||||
incrementalMaxPages = 10
|
||||
incrementalMinPages = 2
|
||||
unchangedPagesToStop = 2
|
||||
preparedPoolReadLimit = 240
|
||||
preparedRowSize = 20
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
Kind string `json:"kind"`
|
||||
Pages int `json:"pages"`
|
||||
Seen int `json:"seen"`
|
||||
Changed int `json:"changed"`
|
||||
Removed int64 `json:"removed"`
|
||||
Duration time.Duration `json:"-"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
tracearr *tracearr.Client
|
||||
engine *recommend.Engine
|
||||
emby *emby.Client
|
||||
serviceCred emby.Credentials
|
||||
log *slog.Logger
|
||||
minRebuildAge time.Duration
|
||||
refreshAge time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
importRunning bool
|
||||
building map[string]bool
|
||||
}
|
||||
|
||||
// ConfigureHouseholdUsers enables background preparation for every enabled Emby user.
|
||||
// The service token is kept server-side and is only used for read-only profile building.
|
||||
func (s *Service) ConfigureHouseholdUsers(client *emby.Client, cred emby.Credentials) {
|
||||
s.emby = client
|
||||
s.serviceCred = cred
|
||||
}
|
||||
|
||||
func New(
|
||||
st *store.Store,
|
||||
tracearrClient *tracearr.Client,
|
||||
engine *recommend.Engine,
|
||||
log *slog.Logger,
|
||||
minRebuildAge, refreshAge time.Duration,
|
||||
) *Service {
|
||||
return &Service{
|
||||
store: st, tracearr: tracearrClient, engine: engine, log: log,
|
||||
minRebuildAge: minRebuildAge, refreshAge: refreshAge,
|
||||
building: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Running() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.importRunning || len(s.building) > 0
|
||||
}
|
||||
|
||||
func (s *Service) Stats(ctx context.Context) (store.ForYouStats, error) {
|
||||
return s.store.ForYouStats(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, resultErr error) {
|
||||
s.mu.Lock()
|
||||
if s.importRunning {
|
||||
s.mu.Unlock()
|
||||
return result, errors.New("for you: a Tracearr import is already running")
|
||||
}
|
||||
s.importRunning = true
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.importRunning = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
started := time.Now().UTC()
|
||||
result.Kind = "incremental"
|
||||
count, err := s.store.TracearrSessionCount(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if count == 0 {
|
||||
full = true
|
||||
}
|
||||
if full {
|
||||
result.Kind = "full"
|
||||
}
|
||||
state, err := s.store.TracearrImportState(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer func() {
|
||||
now := time.Now().UTC()
|
||||
if resultErr != nil {
|
||||
state.LastError = resultErr.Error()
|
||||
} else {
|
||||
state.LastError = ""
|
||||
state.LastIncrementalAt = &now
|
||||
if full {
|
||||
state.LastFullAt = &now
|
||||
}
|
||||
}
|
||||
if err := s.store.SetTracearrImportState(context.WithoutCancel(ctx), state); err != nil {
|
||||
s.log.Error("could not record Tracearr import state", "error", err)
|
||||
}
|
||||
result.Duration = time.Since(started)
|
||||
result.DurationMs = result.Duration.Milliseconds()
|
||||
}()
|
||||
|
||||
unchangedPages := 0
|
||||
for pageNumber := 1; ; pageNumber++ {
|
||||
page, err := s.tracearr.Page(ctx, pageNumber, importPageSize)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Pages++
|
||||
result.Seen += len(page.Data)
|
||||
|
||||
imported := make([]store.TracearrSession, 0, len(page.Data))
|
||||
keys := make([]store.TracearrSessionKey, 0, len(page.Data))
|
||||
for _, session := range page.Data {
|
||||
value, ok := importedSession(session, s.tracearr.ConfiguredServerID())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
imported = append(imported, value)
|
||||
keys = append(keys, store.TracearrSessionKey{
|
||||
ServerID: value.ServerID, SessionID: value.SessionID,
|
||||
})
|
||||
}
|
||||
current, err := s.store.TracearrFingerprints(ctx, keys)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
pageChanged := false
|
||||
for _, session := range imported {
|
||||
key := store.TracearrSessionKey{
|
||||
ServerID: session.ServerID, SessionID: session.SessionID,
|
||||
}
|
||||
if !bytes.Equal(current[key], session.SourceFingerprint) {
|
||||
pageChanged = true
|
||||
result.Changed++
|
||||
}
|
||||
}
|
||||
if err := s.store.UpsertTracearrSessions(ctx, imported, started); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if pageChanged {
|
||||
unchangedPages = 0
|
||||
} else {
|
||||
unchangedPages++
|
||||
}
|
||||
|
||||
reachedEnd := len(page.Data) == 0 ||
|
||||
(page.Meta.Total > 0 && pageNumber*importPageSize >= page.Meta.Total)
|
||||
if full && reachedEnd {
|
||||
break
|
||||
}
|
||||
if !full && (reachedEnd ||
|
||||
(pageNumber >= incrementalMinPages && unchangedPages >= unchangedPagesToStop) ||
|
||||
pageNumber >= incrementalMaxPages) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if full {
|
||||
removed, err := s.store.DeleteTracearrSessionsNotSeenSince(
|
||||
ctx, s.tracearr.ConfiguredServerID(), started,
|
||||
)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Removed = removed
|
||||
}
|
||||
if result.Changed > 0 || result.Removed > 0 {
|
||||
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
users, err := s.store.ActiveRecommendationUsers(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := s.store.MarkForYouDirty(ctx, user.EmbyUserID, user.Username); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
s.log.Info("Tracearr import finished",
|
||||
"kind", result.Kind, "pages", result.Pages, "seen", result.Seen,
|
||||
"changed", result.Changed, "removed", result.Removed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Rebuild(ctx context.Context, sess store.Session, force bool) error {
|
||||
if !s.beginBuild(sess.EmbyUserID) {
|
||||
return nil
|
||||
}
|
||||
defer s.endBuild(sess.EmbyUserID)
|
||||
|
||||
_, poolBuiltAt, dirtySince, err := s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !force && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.minRebuildAge {
|
||||
return nil
|
||||
}
|
||||
if !force && dirtySince == nil && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.refreshAge {
|
||||
return nil
|
||||
}
|
||||
|
||||
tracearrUserID, storedUsername, err := s.store.ForYouTracearrIdentity(ctx, sess.EmbyUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username := strings.TrimSpace(sess.Username)
|
||||
if username == "" {
|
||||
username = storedUsername
|
||||
}
|
||||
imported, err := s.store.TracearrSessionsForUser(ctx, tracearrUserID, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessions := make([]tracearr.Session, 0, len(imported))
|
||||
for _, value := range imported {
|
||||
sessions = append(sessions, tracearrSession(value))
|
||||
}
|
||||
result, err := s.engine.PrepareForYou(ctx, credentials(sess), username, sessions)
|
||||
if err != nil {
|
||||
_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
profile, candidates, err := storedResult(sess.EmbyUserID, time.Now().UTC(), result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, mapping := range result.Mappings {
|
||||
if err := s.store.UpdateTracearrSessionMapping(ctx, store.TracearrSessionKey{
|
||||
ServerID: mapping.ServerID, SessionID: mapping.SessionID,
|
||||
}, mapping.ItemID, mapping.SeriesID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.store.ReplaceForYouPool(ctx, profile, candidates); err != nil {
|
||||
_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
|
||||
return err
|
||||
}
|
||||
s.log.Info("For You pool rebuilt",
|
||||
"user", sess.EmbyUserID, "sessions", len(sessions), "candidates", len(candidates))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) PreparedRows(
|
||||
ctx context.Context,
|
||||
sess store.Session,
|
||||
minutes int,
|
||||
) ([]recommend.Row, bool, bool, error) {
|
||||
items, builtAt, err := s.store.PreparedForYou(
|
||||
ctx, sess.EmbyUserID, minutes, preparedPoolReadLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, false, builtAt != nil && time.Since(*builtAt) >= s.refreshAge, nil
|
||||
}
|
||||
rows := buildPreparedRows(items, minutes, s.engine.MinRowItems)
|
||||
stale := builtAt == nil || time.Since(*builtAt) >= s.refreshAge
|
||||
return rows, len(rows) > 0, stale, nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkDirty(ctx context.Context, sess store.Session) {
|
||||
if err := s.store.MarkForYouDirty(ctx, sess.EmbyUserID, sess.Username); err != nil {
|
||||
s.log.Warn("could not mark For You dirty", "user", sess.EmbyUserID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RefreshAsync(sess store.Session, force bool) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.Rebuild(ctx, sess, force); err != nil {
|
||||
s.log.Warn("For You background rebuild failed", "user", sess.EmbyUserID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) RebuildAll(ctx context.Context, force bool) error {
|
||||
users, err := s.recommendationUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := s.Rebuild(ctx, user, force); err != nil {
|
||||
s.log.Warn("For You user rebuild failed", "user", user.EmbyUserID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkAllDirty(ctx context.Context) {
|
||||
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
|
||||
s.log.Warn("could not mark For You profiles dirty", "error", err)
|
||||
}
|
||||
users, err := s.store.ActiveRecommendationUsers(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("could not list For You users", "error", err)
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
s.MarkDirty(ctx, user)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, error) {
|
||||
active, err := s.store.ActiveRecommendationUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[string]store.Session, len(active))
|
||||
for _, user := range active {
|
||||
byID[user.EmbyUserID] = user
|
||||
}
|
||||
if s.emby == nil || strings.TrimSpace(s.serviceCred.Token) == "" {
|
||||
return active, nil
|
||||
}
|
||||
|
||||
embyUsers, err := s.emby.Users(ctx, s.serviceCred)
|
||||
if err != nil {
|
||||
s.log.Warn("Emby household users unavailable; using signed-in users", "error", err)
|
||||
return active, nil
|
||||
}
|
||||
tracearrUsers, traceErr := s.allTracearrUsers(ctx)
|
||||
if traceErr != nil {
|
||||
s.log.Warn("Tracearr users unavailable; preparing Emby-only profiles", "error", traceErr)
|
||||
}
|
||||
traceByName := map[string]tracearr.User{}
|
||||
for _, user := range tracearrUsers {
|
||||
key := strings.ToLower(strings.TrimSpace(user.Username))
|
||||
current, exists := traceByName[key]
|
||||
if key != "" && (!exists || user.SessionCount > current.SessionCount) {
|
||||
traceByName[key] = user
|
||||
}
|
||||
}
|
||||
|
||||
for _, user := range embyUsers {
|
||||
if user.Policy.IsDisabled || strings.TrimSpace(user.ID) == "" {
|
||||
continue
|
||||
}
|
||||
matched := traceByName[strings.ToLower(strings.TrimSpace(user.Name))]
|
||||
if traceErr == nil {
|
||||
if err := s.store.MatchRecommendationUser(
|
||||
ctx, user.ID, user.Name, matched.ID, matched.Username,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := s.store.MarkForYouDirty(ctx, user.ID, user.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID[user.ID] = store.Session{
|
||||
EmbyUserID: user.ID,
|
||||
EmbyToken: s.serviceCred.Token,
|
||||
Username: user.Name,
|
||||
DeviceID: "memby-for-you-builder",
|
||||
DeviceName: "Memby For You builder",
|
||||
}
|
||||
}
|
||||
out := make([]store.Session, 0, len(byID))
|
||||
for _, user := range byID {
|
||||
out = append(out, user)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) allTracearrUsers(ctx context.Context) ([]tracearr.User, error) {
|
||||
const pageSize = 100
|
||||
out := []tracearr.User{}
|
||||
for pageNumber := 1; ; pageNumber++ {
|
||||
page, err := s.tracearr.Users(ctx, pageNumber, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, page.Data...)
|
||||
if len(page.Data) == 0 || page.Meta.Total <= pageNumber*pageSize {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildPreparedRows(
|
||||
items []store.PreparedForYouItem,
|
||||
minutes, minRowItems int,
|
||||
) []recommend.Row {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
used := map[string]bool{}
|
||||
rows := make([]recommend.Row, 0, 6)
|
||||
appendRowWithMinimum := func(
|
||||
id, title string,
|
||||
candidates []store.PreparedForYouItem,
|
||||
minimum int,
|
||||
) bool {
|
||||
selected := make([]store.PreparedForYouItem, 0, preparedRowSize)
|
||||
for _, item := range candidates {
|
||||
if used[item.ItemID] {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, item)
|
||||
if len(selected) == preparedRowSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(selected) < minimum {
|
||||
return false
|
||||
}
|
||||
rowItems := make([]json.RawMessage, 0, len(selected))
|
||||
for _, item := range selected {
|
||||
used[item.ItemID] = true
|
||||
rowItems = append(rowItems, recommend.EnrichPreparedRecommendation(
|
||||
item.Payload, item.RecommendationReason, item.CompatibilityLabel, minutes,
|
||||
))
|
||||
}
|
||||
rows = append(rows, recommend.Row{
|
||||
ID: id, Title: title, Kind: "for-you", Items: rowItems,
|
||||
})
|
||||
return true
|
||||
}
|
||||
appendRow := func(id, title string, candidates []store.PreparedForYouItem) bool {
|
||||
return appendRowWithMinimum(id, title, candidates, minRowItems)
|
||||
}
|
||||
|
||||
pickups := make([]store.PreparedForYouItem, 0, preparedRowSize)
|
||||
for _, item := range items {
|
||||
if item.ReasonKind == "pick-up" {
|
||||
pickups = append(pickups, item)
|
||||
}
|
||||
}
|
||||
// A pickup is valuable even when only one genuinely abandoned, unfinished series
|
||||
// qualifies. Unlike generic recommendations, padding this shelf would make it lie.
|
||||
appendRowWithMinimum("for-you:pick-up", "Pick these up again", pickups, 1)
|
||||
|
||||
topTitle := "Top picks for you"
|
||||
if minutes > 0 {
|
||||
topTitle = fmt.Sprintf("Top picks that fit in %d minutes", minutes)
|
||||
}
|
||||
appendRow("for-you:picks", topTitle, items)
|
||||
|
||||
appendGroupedRows := func(
|
||||
prefix string,
|
||||
key func(store.PreparedForYouItem) string,
|
||||
title func(store.PreparedForYouItem) string,
|
||||
maxRows int,
|
||||
filter func(store.PreparedForYouItem) bool,
|
||||
) {
|
||||
groups := map[string][]store.PreparedForYouItem{}
|
||||
order := []string{}
|
||||
first := map[string]store.PreparedForYouItem{}
|
||||
for _, item := range items {
|
||||
if used[item.ItemID] || !filter(item) {
|
||||
continue
|
||||
}
|
||||
groupKey := key(item)
|
||||
if groupKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := groups[groupKey]; !exists {
|
||||
order = append(order, groupKey)
|
||||
first[groupKey] = item
|
||||
}
|
||||
groups[groupKey] = append(groups[groupKey], item)
|
||||
}
|
||||
added := 0
|
||||
for _, groupKey := range order {
|
||||
if added == maxRows {
|
||||
return
|
||||
}
|
||||
candidates := groups[groupKey]
|
||||
if len(candidates) < minRowItems {
|
||||
continue
|
||||
}
|
||||
if appendRow(prefix+rowKey(groupKey), title(first[groupKey]), candidates) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendGroupedRows(
|
||||
"for-you:because:",
|
||||
func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID },
|
||||
func(item store.PreparedForYouItem) string {
|
||||
return "Because you finished " + item.ReasonSourceTitle
|
||||
},
|
||||
2,
|
||||
func(item store.PreparedForYouItem) bool {
|
||||
return strings.TrimSpace(item.ReasonSourceTitle) != ""
|
||||
},
|
||||
)
|
||||
appendGroupedRows(
|
||||
"for-you:genre:",
|
||||
func(item store.PreparedForYouItem) string {
|
||||
return strings.ToLower(strings.TrimSpace(item.ReasonGenre))
|
||||
},
|
||||
func(item store.PreparedForYouItem) string {
|
||||
return "More " + item.ReasonGenre + " for you"
|
||||
},
|
||||
2,
|
||||
func(item store.PreparedForYouItem) bool {
|
||||
return strings.TrimSpace(item.ReasonGenre) != ""
|
||||
},
|
||||
)
|
||||
compatible := make([]store.PreparedForYouItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !used[item.ItemID] && item.CompatibilityScore > 0.2 {
|
||||
compatible = append(compatible, item)
|
||||
}
|
||||
}
|
||||
appendRow("for-you:tv-ready", "Plays well on this TV", compatible)
|
||||
return rows
|
||||
}
|
||||
|
||||
func rowKey(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return fmt.Sprintf("%x", sum[:6])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Service) Schedule(
|
||||
ctx context.Context,
|
||||
importEvery, fullEvery, refreshEvery time.Duration,
|
||||
) {
|
||||
if importEvery <= 0 {
|
||||
s.log.Info("Tracearr auto-import disabled")
|
||||
return
|
||||
}
|
||||
importTicker := time.NewTicker(importEvery)
|
||||
defer importTicker.Stop()
|
||||
var fullC, refreshC <-chan time.Time
|
||||
var fullTicker, refreshTicker *time.Ticker
|
||||
if fullEvery > 0 {
|
||||
fullTicker = time.NewTicker(fullEvery)
|
||||
fullC = fullTicker.C
|
||||
defer fullTicker.Stop()
|
||||
}
|
||||
if refreshEvery > 0 {
|
||||
refreshTicker = time.NewTicker(refreshEvery)
|
||||
refreshC = refreshTicker.C
|
||||
defer refreshTicker.Stop()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-importTicker.C:
|
||||
if _, err := s.Import(ctx, false); err != nil {
|
||||
s.log.Warn("scheduled Tracearr import failed", "error", err)
|
||||
} else {
|
||||
_ = s.RebuildAll(ctx, false)
|
||||
}
|
||||
case <-fullC:
|
||||
if _, err := s.Import(ctx, true); err != nil {
|
||||
s.log.Warn("scheduled full Tracearr import failed", "error", err)
|
||||
} else {
|
||||
_ = s.RebuildAll(ctx, false)
|
||||
}
|
||||
case <-refreshC:
|
||||
_ = s.RebuildAll(ctx, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) beginBuild(userID string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.building[userID] {
|
||||
return false
|
||||
}
|
||||
s.building[userID] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) endBuild(userID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.building, userID)
|
||||
}
|
||||
|
||||
func importedSession(session tracearr.Session, configuredServerID string) (store.TracearrSession, bool) {
|
||||
if strings.TrimSpace(session.ID) == "" {
|
||||
return store.TracearrSession{}, false
|
||||
}
|
||||
serverID := strings.TrimSpace(session.ServerID)
|
||||
if serverID == "" {
|
||||
serverID = strings.TrimSpace(configuredServerID)
|
||||
}
|
||||
if serverID == "" {
|
||||
serverID = "default"
|
||||
}
|
||||
startedAt := parsedTime(session.StartedAt)
|
||||
stoppedAt := parsedTime(session.StoppedAt)
|
||||
fingerprintRaw, _ := json.Marshal(session)
|
||||
fingerprint := sha256.Sum256(fingerprintRaw)
|
||||
return store.TracearrSession{
|
||||
ServerID: serverID, SessionID: session.ID, UserID: session.User.ID,
|
||||
Username: session.User.Username, State: session.State,
|
||||
MediaType: session.MediaType, MediaTitle: session.MediaTitle,
|
||||
ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
|
||||
EpisodeNumber: session.EpisodeNumber, ProductionYear: session.Year,
|
||||
StartedAt: startedAt, StoppedAt: stoppedAt,
|
||||
DurationMs: int64(session.DurationMs), ProgressMs: int64(session.ProgressMs),
|
||||
TotalDurationMs: int64(session.TotalDurationMs), Watched: session.Watched,
|
||||
Device: session.Device, Player: session.Player, Product: session.Product,
|
||||
Platform: session.Platform, IsTranscode: session.IsTranscode,
|
||||
VideoDecision: session.VideoDecision, AudioDecision: session.AudioDecision,
|
||||
SourceVideoCodec: session.SourceVideoCodec, SourceAudioCodec: session.SourceAudioCodec,
|
||||
SourceFingerprint: fingerprint[:],
|
||||
}, true
|
||||
}
|
||||
|
||||
func tracearrSession(session store.TracearrSession) tracearr.Session {
|
||||
value := tracearr.Session{
|
||||
ID: session.SessionID, ServerID: session.ServerID, State: session.State,
|
||||
MediaType: session.MediaType, MediaTitle: session.MediaTitle,
|
||||
ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
|
||||
EpisodeNumber: session.EpisodeNumber, Year: session.ProductionYear,
|
||||
DurationMs: tracearr.FlexibleInt64(session.DurationMs),
|
||||
ProgressMs: tracearr.FlexibleInt64(session.ProgressMs),
|
||||
TotalDurationMs: tracearr.FlexibleInt64(session.TotalDurationMs),
|
||||
Watched: session.Watched, Device: session.Device, Player: session.Player,
|
||||
Product: session.Product, Platform: session.Platform,
|
||||
IsTranscode: session.IsTranscode, VideoDecision: session.VideoDecision,
|
||||
AudioDecision: session.AudioDecision, SourceVideoCodec: session.SourceVideoCodec,
|
||||
SourceAudioCodec: session.SourceAudioCodec,
|
||||
}
|
||||
if session.StartedAt != nil {
|
||||
value.StartedAt = session.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if session.StoppedAt != nil {
|
||||
value.StoppedAt = session.StoppedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
value.User.ID = session.UserID
|
||||
value.User.Username = session.Username
|
||||
return value
|
||||
}
|
||||
|
||||
func storedResult(
|
||||
userID string,
|
||||
builtAt time.Time,
|
||||
result recommend.PreparedResult,
|
||||
) (store.RecommendationProfile, []store.ForYouCandidate, error) {
|
||||
genre, err := json.Marshal(result.Profile.GenreAffinity)
|
||||
if err != nil {
|
||||
return store.RecommendationProfile{}, nil, err
|
||||
}
|
||||
title, err := json.Marshal(result.Profile.TitleAffinity)
|
||||
if err != nil {
|
||||
return store.RecommendationProfile{}, nil, err
|
||||
}
|
||||
studio, err := json.Marshal(result.Profile.StudioAffinity)
|
||||
if err != nil {
|
||||
return store.RecommendationProfile{}, nil, err
|
||||
}
|
||||
codecs, err := json.Marshal(result.Profile.CodecOutcomes)
|
||||
if err != nil {
|
||||
return store.RecommendationProfile{}, nil, err
|
||||
}
|
||||
profile := store.RecommendationProfile{
|
||||
EmbyUserID: userID, TracearrUserID: result.Profile.TracearrUserID,
|
||||
TracearrUsername: result.Profile.TracearrUsername,
|
||||
SourceSessionCount: result.Profile.SourceSessionCount,
|
||||
MeanCompletionRatio: result.Profile.MeanCompletionRatio,
|
||||
TypicalSessionMinutes: result.Profile.TypicalSessionMinutes,
|
||||
GenreAffinity: genre, TitleAffinity: title, StudioAffinity: studio,
|
||||
CodecOutcomes: codecs, SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
|
||||
}
|
||||
candidates := make([]store.ForYouCandidate, 0, len(result.Candidates))
|
||||
for _, value := range result.Candidates {
|
||||
candidates = append(candidates, store.ForYouCandidate{
|
||||
ItemID: value.ItemID, BaseRank: value.BaseRank, BaseScore: value.BaseScore,
|
||||
RuntimeMinutes: value.RuntimeMinutes, AffinityScore: value.AffinityScore,
|
||||
CompatibilityScore: value.CompatibilityScore,
|
||||
CompatibilityLabel: value.CompatibilityLabel, ReasonKind: value.ReasonKind,
|
||||
ReasonGenre: value.ReasonGenre,
|
||||
ReasonSourceSessionID: value.ReasonSourceSessionID,
|
||||
ReasonSourceItemID: value.ReasonSourceItemID,
|
||||
ReasonSourceTitle: value.ReasonSourceTitle,
|
||||
RecommendationReason: value.RecommendationReason,
|
||||
})
|
||||
}
|
||||
return profile, candidates, nil
|
||||
}
|
||||
|
||||
func parsedTime(value string) *time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed = parsed.UTC()
|
||||
return &parsed
|
||||
}
|
||||
|
||||
func credentials(sess store.Session) emby.Credentials {
|
||||
return emby.Credentials{
|
||||
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
|
||||
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package foryou
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||
)
|
||||
|
||||
func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) {
|
||||
session := tracearr.Session{
|
||||
ID: "session-1", MediaType: "movie", MediaTitle: "Arrival",
|
||||
ProgressMs: 500, TotalDurationMs: 1000,
|
||||
}
|
||||
session.User.ID = "user-1"
|
||||
session.User.Username = "Matt"
|
||||
|
||||
first, ok := importedSession(session, "configured-server")
|
||||
if !ok {
|
||||
t.Fatal("session was rejected")
|
||||
}
|
||||
if first.ServerID != "configured-server" || first.SessionID != "session-1" {
|
||||
t.Fatalf("key = %q/%q", first.ServerID, first.SessionID)
|
||||
}
|
||||
second, _ := importedSession(session, "configured-server")
|
||||
if !bytes.Equal(first.SourceFingerprint, second.SourceFingerprint) {
|
||||
t.Fatal("unchanged session did not produce a stable fingerprint")
|
||||
}
|
||||
session.ProgressMs = 750
|
||||
updated, _ := importedSession(session, "configured-server")
|
||||
if bytes.Equal(first.SourceFingerprint, updated.SourceFingerprint) {
|
||||
t.Fatal("updated progress was not detected by the fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) {
|
||||
items := make([]store.PreparedForYouItem, 0, 120)
|
||||
for i := 0; i < 120; i++ {
|
||||
sourceID, sourceTitle := "six-feet-under", "Six Feet Under"
|
||||
genre := "Drama"
|
||||
if i%2 == 1 {
|
||||
sourceID, sourceTitle = "arrival", "Arrival"
|
||||
genre = "Science Fiction"
|
||||
}
|
||||
id := "item-" + itoaForTest(i)
|
||||
items = append(items, store.PreparedForYouItem{
|
||||
ItemID: id, BaseRank: i + 1,
|
||||
Payload: json.RawMessage(`{"Id":"` + id + `"}`),
|
||||
CompatibilityScore: 0.8,
|
||||
CompatibilityLabel: "Direct plays well on this TV",
|
||||
RecommendationReason: "Because you finished " + sourceTitle,
|
||||
ReasonGenre: genre, ReasonSourceItemID: sourceID,
|
||||
ReasonSourceTitle: sourceTitle,
|
||||
})
|
||||
}
|
||||
|
||||
rows := buildPreparedRows(items, 0, 4)
|
||||
if len(rows) < 4 {
|
||||
t.Fatalf("expected several prepared rows, got %d", len(rows))
|
||||
}
|
||||
titles := map[string]bool{}
|
||||
itemIDs := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
titles[row.Title] = true
|
||||
for _, raw := range row.Items {
|
||||
var item struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if itemIDs[item.ID] {
|
||||
t.Fatalf("item %q appeared in more than one row", item.ID)
|
||||
}
|
||||
itemIDs[item.ID] = true
|
||||
}
|
||||
}
|
||||
if !titles["Because you finished Six Feet Under"] ||
|
||||
!titles["Because you finished Arrival"] {
|
||||
t.Fatalf("completed-title rows = %+v", titles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) {
|
||||
rows := buildPreparedRows([]store.PreparedForYouItem{{
|
||||
ItemID: "show-1", BaseRank: 1,
|
||||
Payload: json.RawMessage(`{"Id":"show-1"}`),
|
||||
ReasonKind: "pick-up",
|
||||
RecommendationReason: "You left this in season 1 · pick it up again",
|
||||
}}, 0, 4)
|
||||
|
||||
if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
|
||||
rows[0].Title != "Pick these up again" || len(rows[0].Items) != 1 {
|
||||
t.Fatalf("pickup rows = %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func itoaForTest(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var digits [20]byte
|
||||
position := len(digits)
|
||||
for value > 0 {
|
||||
position--
|
||||
digits[position] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits[position:])
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Package logging configures the server's human-readable, structured log output.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseLevel returns a supported slog level, defaulting to INFO for empty or invalid
|
||||
// values. Keeping this forgiving prevents a typo in Docker configuration from stopping
|
||||
// the gateway.
|
||||
func ParseLevel(value string) slog.Level {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "DEBUG":
|
||||
return slog.LevelDebug
|
||||
case "WARN", "WARNING":
|
||||
return slog.LevelWarn
|
||||
case "ERROR":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a text logger suited to `docker compose logs`. Fields remain structured
|
||||
// key=value pairs, but each event is one compact line rather than a JSON object.
|
||||
func New(w io.Writer, level slog.Leveler) *slog.Logger {
|
||||
logger, _ := NewBuffered(w, level, 0)
|
||||
return logger
|
||||
}
|
||||
|
||||
// Event is the browser-safe representation of one structured server log record.
|
||||
type Event struct {
|
||||
Sequence int64 `json:"sequence"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// EventPage is cursor based so an admin browser can drain bursts without repeatedly
|
||||
// downloading records it has already rendered.
|
||||
type EventPage struct {
|
||||
Events []Event `json:"events"`
|
||||
Next int64 `json:"next"`
|
||||
Oldest int64 `json:"oldest"`
|
||||
Latest int64 `json:"latest"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
// Buffer is a bounded, concurrency-safe ring of recent structured log records.
|
||||
// Bounding is important: a broken TV can generate traffic indefinitely, while 20,000
|
||||
// records is still enough context for an operator to inspect a sustained incident.
|
||||
type Buffer struct {
|
||||
mu sync.RWMutex
|
||||
capacity int
|
||||
events []Event
|
||||
next atomic.Int64
|
||||
}
|
||||
|
||||
// NewBuffered writes normal text logs and mirrors accepted records into a ring buffer.
|
||||
func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *Buffer) {
|
||||
options := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
|
||||
if attr.Key == slog.TimeKey {
|
||||
return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339))
|
||||
}
|
||||
return attr
|
||||
},
|
||||
}
|
||||
buffer := &Buffer{capacity: capacity}
|
||||
text := slog.NewTextHandler(w, options)
|
||||
if capacity <= 0 {
|
||||
return slog.New(text), buffer
|
||||
}
|
||||
return slog.New(&captureHandler{next: text, buffer: buffer, level: level}), buffer
|
||||
}
|
||||
|
||||
type captureHandler struct {
|
||||
next slog.Handler
|
||||
buffer *Buffer
|
||||
level slog.Leveler
|
||||
attrs []slog.Attr
|
||||
groups []string
|
||||
}
|
||||
|
||||
func (h *captureHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return level >= h.level.Level() && h.next.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *captureHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
attributes := make(map[string]string, record.NumAttrs()+len(h.attrs))
|
||||
for _, attr := range h.attrs {
|
||||
addAttribute(attributes, h.groups, attr)
|
||||
}
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
addAttribute(attributes, h.groups, attr)
|
||||
return true
|
||||
})
|
||||
h.buffer.append(Event{
|
||||
OccurredAt: record.Time.UTC(),
|
||||
Level: record.Level.String(),
|
||||
Message: record.Message,
|
||||
Attributes: attributes,
|
||||
})
|
||||
return h.next.Handle(ctx, record)
|
||||
}
|
||||
|
||||
func (h *captureHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
clone := *h
|
||||
clone.next = h.next.WithAttrs(attrs)
|
||||
clone.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (h *captureHandler) WithGroup(name string) slog.Handler {
|
||||
clone := *h
|
||||
clone.next = h.next.WithGroup(name)
|
||||
clone.groups = append(append([]string{}, h.groups...), name)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
return
|
||||
}
|
||||
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
|
||||
if attr.Value.Kind() == slog.KindGroup {
|
||||
for _, child := range attr.Value.Group() {
|
||||
addAttribute(target, append(groups, attr.Key), child)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch attr.Value.Kind() {
|
||||
case slog.KindDuration:
|
||||
target[key] = attr.Value.Duration().String()
|
||||
case slog.KindTime:
|
||||
target[key] = attr.Value.Time().UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
target[key] = attr.Value.String()
|
||||
if attr.Value.Kind() == slog.KindInt64 {
|
||||
target[key] = strconv.FormatInt(attr.Value.Int64(), 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) append(event Event) {
|
||||
event.Sequence = b.next.Add(1)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if len(b.events) == b.capacity {
|
||||
copy(b.events, b.events[1:])
|
||||
b.events[len(b.events)-1] = event
|
||||
return
|
||||
}
|
||||
b.events = append(b.events, event)
|
||||
}
|
||||
|
||||
// Events returns records strictly newer than after, up to limit. If the caller fell
|
||||
// behind the ring, Dropped reports the gap and delivery resumes at the oldest record.
|
||||
func (b *Buffer) Events(after int64, limit int) EventPage {
|
||||
if limit < 1 {
|
||||
limit = 250
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
latest := b.next.Load()
|
||||
page := EventPage{Next: after, Latest: latest, Events: []Event{}}
|
||||
if len(b.events) == 0 {
|
||||
return page
|
||||
}
|
||||
page.Oldest = b.events[0].Sequence
|
||||
if after < page.Oldest-1 {
|
||||
page.Dropped = page.Oldest - after - 1
|
||||
after = page.Oldest - 1
|
||||
}
|
||||
start := len(b.events)
|
||||
for i := range b.events {
|
||||
if b.events[i].Sequence > after {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
end := min(start+limit, len(b.events))
|
||||
page.Events = append(page.Events, b.events[start:end]...)
|
||||
if len(page.Events) > 0 {
|
||||
page.Next = page.Events[len(page.Events)-1].Sequence
|
||||
}
|
||||
page.HasMore = page.Next < latest
|
||||
return page
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewWritesReadableStructuredText(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
New(&output, slog.LevelInfo).Info("server ready", "listen", ":8080")
|
||||
|
||||
line := output.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "{") {
|
||||
t.Fatalf("expected text output, got JSON: %s", line)
|
||||
}
|
||||
for _, want := range []string{`level=INFO`, `msg="server ready"`, `listen=:8080`} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("output %q does not contain %q", line, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger, buffer := NewBuffered(&output, slog.LevelDebug, 3)
|
||||
for i := 1; i <= 5; i++ {
|
||||
logger.Info("request complete", "number", i)
|
||||
}
|
||||
|
||||
first := buffer.Events(0, 2)
|
||||
if first.Dropped != 2 || len(first.Events) != 2 || !first.HasMore {
|
||||
t.Fatalf("unexpected first page: %+v", first)
|
||||
}
|
||||
if first.Events[0].Sequence != 3 || first.Events[0].Attributes["number"] != "3" {
|
||||
t.Fatalf("oldest retained event was not delivered: %+v", first.Events[0])
|
||||
}
|
||||
second := buffer.Events(first.Next, 2)
|
||||
if len(second.Events) != 1 || second.Events[0].Sequence != 5 || second.HasMore {
|
||||
t.Fatalf("unexpected second page: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := map[string]slog.Level{
|
||||
"": slog.LevelInfo,
|
||||
"debug": slog.LevelDebug,
|
||||
"WARNING": slog.LevelWarn,
|
||||
"error": slog.LevelError,
|
||||
"unknown": slog.LevelInfo,
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := ParseLevel(input); got != want {
|
||||
t.Errorf("ParseLevel(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||
)
|
||||
|
||||
type PreparedLibrarySource interface {
|
||||
AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
type PreparedEvidence struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ItemID string `json:"itemId"`
|
||||
Title string `json:"title"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
}
|
||||
|
||||
type PreparedTitleAffinity struct {
|
||||
Weight float64 `json:"weight"`
|
||||
Title string `json:"title"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
}
|
||||
|
||||
type PreparedSessionMapping struct {
|
||||
ServerID string
|
||||
SessionID string
|
||||
ItemID string
|
||||
SeriesID string
|
||||
}
|
||||
|
||||
type PreparedProfile struct {
|
||||
TracearrUserID string
|
||||
TracearrUsername string
|
||||
SourceSessionCount int
|
||||
MeanCompletionRatio float64
|
||||
TypicalSessionMinutes int
|
||||
GenreAffinity map[string]float64
|
||||
TitleAffinity map[string]PreparedTitleAffinity
|
||||
StudioAffinity map[string]float64
|
||||
CodecOutcomes map[string]map[string]int
|
||||
SignalsThrough *time.Time
|
||||
}
|
||||
|
||||
type PreparedCandidate struct {
|
||||
ItemID string
|
||||
BaseRank int
|
||||
BaseScore float64
|
||||
RuntimeMinutes int
|
||||
AffinityScore float64
|
||||
CompatibilityScore float64
|
||||
CompatibilityLabel string
|
||||
ReasonKind string
|
||||
ReasonGenre string
|
||||
ReasonSourceSessionID string
|
||||
ReasonSourceItemID string
|
||||
ReasonSourceTitle string
|
||||
RecommendationReason string
|
||||
}
|
||||
|
||||
type PreparedResult struct {
|
||||
Profile PreparedProfile
|
||||
Candidates []PreparedCandidate
|
||||
Mappings []PreparedSessionMapping
|
||||
}
|
||||
|
||||
var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable")
|
||||
|
||||
// PrepareForYou performs the expensive work outside a television request. It consumes
|
||||
// locally imported Tracearr sessions and the complete imported catalogue, producing a
|
||||
// compact profile and an intentionally over-provisioned ranked pool.
|
||||
func (e *Engine) PrepareForYou(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
username string,
|
||||
sessions []tracearr.Session,
|
||||
) (PreparedResult, error) {
|
||||
sessions = recommendationSessions(sessions)
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return PreparedResult{}, err
|
||||
}
|
||||
profile := BuildProfile(history, favorites)
|
||||
|
||||
library, ok := e.Library.(PreparedLibrarySource)
|
||||
if !ok {
|
||||
return PreparedResult{}, ErrPreparedLibraryUnavailable
|
||||
}
|
||||
raws, err := library.AllRecommendationCandidates(ctx)
|
||||
if err != nil {
|
||||
return PreparedResult{}, err
|
||||
}
|
||||
catalogue := Decode(raws)
|
||||
if len(catalogue) == 0 {
|
||||
return PreparedResult{}, ErrPreparedLibraryUnavailable
|
||||
}
|
||||
|
||||
browsed := map[string]bool{}
|
||||
if e.Behavior != nil {
|
||||
browsedRaws, browseErr := e.Behavior.BrowsingCandidates(
|
||||
ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 30,
|
||||
)
|
||||
if browseErr != nil {
|
||||
e.log.Warn("browsing signals unavailable during For You rebuild", "error", browseErr)
|
||||
} else {
|
||||
for i, item := range Decode(browsedRaws) {
|
||||
profile.absorbTaste(item, 0.55*powDecay(0.92, i))
|
||||
browsed[item.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
index := newCatalogueIndex(catalogue)
|
||||
evidenceByGenre := map[string][]PreparedEvidence{}
|
||||
evidenceSeen := map[string]bool{}
|
||||
addCompletedEvidence := func(item Item, sessionID string) {
|
||||
itemID := item.ID
|
||||
title := item.Name
|
||||
if strings.EqualFold(item.Type, "Episode") &&
|
||||
strings.TrimSpace(item.SeriesName) != "" {
|
||||
title = item.SeriesName
|
||||
if item.SeriesID != "" {
|
||||
itemID = item.SeriesID
|
||||
}
|
||||
}
|
||||
for _, genre := range item.Genres {
|
||||
genreKey := strings.ToLower(strings.TrimSpace(genre))
|
||||
evidenceKey := genreKey + "|" + itemID
|
||||
if genreKey == "" || itemID == "" || evidenceSeen[evidenceKey] {
|
||||
continue
|
||||
}
|
||||
evidenceSeen[evidenceKey] = true
|
||||
evidenceByGenre[genreKey] = append(
|
||||
evidenceByGenre[genreKey],
|
||||
PreparedEvidence{
|
||||
SessionID: sessionID,
|
||||
ItemID: itemID,
|
||||
Title: title,
|
||||
Genres: append([]string(nil), item.Genres...),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// Emby's played history is already a trustworthy completion signal and gives the
|
||||
// explanation pool breadth even when Tracearr title matching is sparse.
|
||||
for _, item := range history {
|
||||
if item.UserData.Played {
|
||||
addCompletedEvidence(item, "")
|
||||
}
|
||||
}
|
||||
titleAffinity := map[string]PreparedTitleAffinity{}
|
||||
mappings := make([]PreparedSessionMapping, 0, len(sessions))
|
||||
var completionTotal float64
|
||||
durations := make([]int, 0, len(sessions))
|
||||
var signalsThrough *time.Time
|
||||
tracearrUserID := ""
|
||||
tracearrUsername := strings.TrimSpace(username)
|
||||
titleSignalCount := map[string]int{}
|
||||
|
||||
for i, session := range sessions {
|
||||
completion := session.Completion()
|
||||
completionTotal += completion
|
||||
if minutes := int(int64(session.DurationMs) / 60_000); minutes > 0 {
|
||||
durations = append(durations, minutes)
|
||||
}
|
||||
if started, ok := parseTracearrTime(session.StartedAt); ok &&
|
||||
(signalsThrough == nil || started.After(*signalsThrough)) {
|
||||
value := started
|
||||
signalsThrough = &value
|
||||
}
|
||||
if tracearrUserID == "" {
|
||||
tracearrUserID = strings.TrimSpace(session.User.ID)
|
||||
}
|
||||
if tracearrUsername == "" {
|
||||
tracearrUsername = strings.TrimSpace(session.User.Username)
|
||||
}
|
||||
if completion > 0 {
|
||||
profile.SeenTitles[tracearrSeenKey(session)] = true
|
||||
}
|
||||
|
||||
item, matched := index.match(session)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
seriesID := item.SeriesID
|
||||
if strings.EqualFold(session.MediaType, "episode") &&
|
||||
strings.EqualFold(item.Type, "Series") {
|
||||
seriesID = item.ID
|
||||
}
|
||||
mappings = append(mappings, PreparedSessionMapping{
|
||||
ServerID: session.ServerID, SessionID: session.ID,
|
||||
ItemID: item.ID, SeriesID: seriesID,
|
||||
})
|
||||
|
||||
// Episode-heavy programmes should be strong signals, but not dozens of
|
||||
// independent votes. Each repeat contributes less than the previous one.
|
||||
repeats := titleSignalCount[item.ID]
|
||||
titleSignalCount[item.ID] = repeats + 1
|
||||
weight := (0.2 + completion) * math.Pow(0.985, float64(i)) *
|
||||
math.Pow(0.65, float64(repeats))
|
||||
profile.absorbTaste(item, weight)
|
||||
if completion > 0 {
|
||||
if item.ID != "" {
|
||||
profile.Seen[item.ID] = true
|
||||
}
|
||||
if item.SeriesID != "" {
|
||||
profile.Seen[item.SeriesID] = true
|
||||
}
|
||||
}
|
||||
current := titleAffinity[item.ID]
|
||||
current.Weight += weight
|
||||
current.Title = item.Name
|
||||
if current.SessionID == "" {
|
||||
current.SessionID = session.ID
|
||||
}
|
||||
titleAffinity[item.ID] = current
|
||||
|
||||
if completion >= 0.9 {
|
||||
addCompletedEvidence(item, session.ID)
|
||||
}
|
||||
}
|
||||
|
||||
compatibility := buildCompatibilityProfile(sessions)
|
||||
type scored struct {
|
||||
item Item
|
||||
base float64
|
||||
compatibility float64
|
||||
score float64
|
||||
}
|
||||
ranked := make([]scored, 0, len(catalogue))
|
||||
seenCandidates := map[string]bool{}
|
||||
for _, candidate := range catalogue {
|
||||
if candidate.ID == "" || seenCandidates[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
seenCandidates[candidate.ID] = true
|
||||
base := profile.Score(candidate)
|
||||
if base < 0 {
|
||||
continue
|
||||
}
|
||||
compatibilityValue := compatibilityScore(candidate, compatibility)
|
||||
ranked = append(ranked, scored{
|
||||
item: candidate, base: base, compatibility: compatibilityValue,
|
||||
score: base + compatibilityValue*1.4,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
return ranked[i].item.Name < ranked[j].item.Name
|
||||
})
|
||||
|
||||
prepared := make([]PreparedCandidate, 0, len(ranked))
|
||||
completedReasonCounts := map[string]int{}
|
||||
for rank, entry := range ranked {
|
||||
reason, label, kind, genre, evidence := explainPreparedRecommendation(
|
||||
profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre,
|
||||
completedReasonCounts,
|
||||
)
|
||||
prepared = append(prepared, PreparedCandidate{
|
||||
ItemID: entry.item.ID,
|
||||
BaseRank: rank + 1,
|
||||
BaseScore: entry.score,
|
||||
RuntimeMinutes: entry.item.RuntimeMinutes(),
|
||||
AffinityScore: entry.base,
|
||||
CompatibilityScore: entry.compatibility,
|
||||
CompatibilityLabel: label,
|
||||
ReasonKind: kind,
|
||||
ReasonGenre: genre,
|
||||
ReasonSourceSessionID: evidence.SessionID,
|
||||
ReasonSourceItemID: evidence.ItemID,
|
||||
ReasonSourceTitle: evidence.Title,
|
||||
RecommendationReason: reason,
|
||||
})
|
||||
}
|
||||
pickups := e.prepareAbandonedShows(ctx, cred, index, sessions, compatibility, time.Now())
|
||||
if len(pickups) > 0 {
|
||||
for i := range prepared {
|
||||
prepared[i].BaseRank += len(pickups)
|
||||
}
|
||||
for i := range pickups {
|
||||
pickups[i].BaseRank = i + 1
|
||||
}
|
||||
prepared = append(pickups, prepared...)
|
||||
}
|
||||
|
||||
meanCompletion := 0.0
|
||||
if len(sessions) > 0 {
|
||||
meanCompletion = completionTotal / float64(len(sessions))
|
||||
}
|
||||
codecs := map[string]map[string]int{
|
||||
"direct": compatibility.directCodecs,
|
||||
"transcode": compatibility.transcodeCodecs,
|
||||
}
|
||||
return PreparedResult{
|
||||
Profile: PreparedProfile{
|
||||
TracearrUserID: tracearrUserID, TracearrUsername: tracearrUsername,
|
||||
SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
|
||||
TypicalSessionMinutes: medianInt(durations),
|
||||
GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity,
|
||||
StudioAffinity: profile.StudioWeights, CodecOutcomes: codecs,
|
||||
SignalsThrough: signalsThrough,
|
||||
},
|
||||
Candidates: prepared,
|
||||
Mappings: mappings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
abandonedShowAge = 21 * 24 * time.Hour
|
||||
maxPickupShows = 20
|
||||
)
|
||||
|
||||
type abandonedShowProgress struct {
|
||||
item Item
|
||||
lastActivity time.Time
|
||||
lastSeason int
|
||||
completedEpisodes map[string]bool
|
||||
}
|
||||
|
||||
// prepareAbandonedShows adds watched series back into the otherwise-unwatched candidate
|
||||
// pool. Emby Next Up is the completion boundary: if Emby has no next episode for this
|
||||
// user, the show is complete and cannot appear here.
|
||||
func (e *Engine) prepareAbandonedShows(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
index catalogueIndex,
|
||||
sessions []tracearr.Session,
|
||||
compatibility compatibilityProfile,
|
||||
now time.Time,
|
||||
) []PreparedCandidate {
|
||||
nextUpSource, ok := e.source.(NextUpSource)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result, err := nextUpSource.NextUp(ctx, cred, url.Values{
|
||||
"Limit": {"5000"},
|
||||
"Fields": {"SeriesName,SeriesId,ParentIndexNumber,IndexNumber,RunTimeTicks"},
|
||||
"EnableImages": {"false"},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableTotalRecordCount": {"false"},
|
||||
})
|
||||
if err != nil {
|
||||
e.log.Warn("could not check abandoned shows against Emby Next Up", "error", err)
|
||||
return nil
|
||||
}
|
||||
return abandonedShowCandidates(index, sessions, Decode(result.Items), compatibility, now)
|
||||
}
|
||||
|
||||
func abandonedShowCandidates(
|
||||
index catalogueIndex,
|
||||
sessions []tracearr.Session,
|
||||
nextUp []Item,
|
||||
compatibility compatibilityProfile,
|
||||
now time.Time,
|
||||
) []PreparedCandidate {
|
||||
progress := map[string]*abandonedShowProgress{}
|
||||
for _, session := range sessions {
|
||||
if !strings.EqualFold(session.MediaType, "episode") ||
|
||||
strings.TrimSpace(session.ShowTitle) == "" ||
|
||||
session.Completion() < 0.1 {
|
||||
continue
|
||||
}
|
||||
series, matched := index.match(session)
|
||||
if !matched || series.ID == "" || !strings.EqualFold(series.Type, "Series") {
|
||||
continue
|
||||
}
|
||||
activity, ok := tracearrActivityTime(session)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
current := progress[series.ID]
|
||||
if current == nil {
|
||||
current = &abandonedShowProgress{
|
||||
item: series, completedEpisodes: map[string]bool{},
|
||||
}
|
||||
progress[series.ID] = current
|
||||
}
|
||||
if activity.After(current.lastActivity) {
|
||||
current.lastActivity = activity
|
||||
if session.SeasonNumber != nil {
|
||||
current.lastSeason = *session.SeasonNumber
|
||||
}
|
||||
}
|
||||
if session.Completion() >= 0.9 && session.SeasonNumber != nil &&
|
||||
session.EpisodeNumber != nil {
|
||||
key := strconv.Itoa(*session.SeasonNumber) + ":" + strconv.Itoa(*session.EpisodeNumber)
|
||||
current.completedEpisodes[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
nextBySeries := map[string]Item{}
|
||||
for _, episode := range nextUp {
|
||||
// Specials do not mean the main programme is unfinished.
|
||||
if episode.SeriesID == "" || episode.ParentIndexNumber <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := nextBySeries[episode.SeriesID]; !exists {
|
||||
nextBySeries[episode.SeriesID] = episode
|
||||
}
|
||||
}
|
||||
|
||||
type pickup struct {
|
||||
progress *abandonedShowProgress
|
||||
next Item
|
||||
}
|
||||
eligible := make([]pickup, 0, len(progress))
|
||||
cutoff := now.Add(-abandonedShowAge)
|
||||
for seriesID, watched := range progress {
|
||||
next, unfinished := nextBySeries[seriesID]
|
||||
if !unfinished || watched.lastActivity.After(cutoff) {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, pickup{progress: watched, next: next})
|
||||
}
|
||||
sort.SliceStable(eligible, func(i, j int) bool {
|
||||
iLaterSeason := eligible[i].progress.lastSeason > 1
|
||||
jLaterSeason := eligible[j].progress.lastSeason > 1
|
||||
if iLaterSeason != jLaterSeason {
|
||||
return iLaterSeason
|
||||
}
|
||||
if !eligible[i].progress.lastActivity.Equal(eligible[j].progress.lastActivity) {
|
||||
return eligible[i].progress.lastActivity.After(eligible[j].progress.lastActivity)
|
||||
}
|
||||
return len(eligible[i].progress.completedEpisodes) >
|
||||
len(eligible[j].progress.completedEpisodes)
|
||||
})
|
||||
if len(eligible) > maxPickupShows {
|
||||
eligible = eligible[:maxPickupShows]
|
||||
}
|
||||
|
||||
out := make([]PreparedCandidate, 0, len(eligible))
|
||||
for _, candidate := range eligible {
|
||||
watched := candidate.progress
|
||||
next := candidate.next
|
||||
reason := abandonedShowReason(watched.lastSeason, next.ParentIndexNumber)
|
||||
compatibilityValue := compatibilityScore(watched.item, compatibility)
|
||||
out = append(out, PreparedCandidate{
|
||||
ItemID: watched.item.ID,
|
||||
RuntimeMinutes: next.RuntimeMinutes(),
|
||||
BaseScore: float64(len(watched.completedEpisodes)),
|
||||
AffinityScore: float64(len(watched.completedEpisodes)),
|
||||
CompatibilityScore: compatibilityValue,
|
||||
CompatibilityLabel: compatibilityLabelForScore(compatibilityValue),
|
||||
ReasonKind: "pick-up",
|
||||
ReasonSourceItemID: next.ID,
|
||||
ReasonSourceTitle: next.Name,
|
||||
RecommendationReason: reason,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tracearrActivityTime(session tracearr.Session) (time.Time, bool) {
|
||||
if stopped, ok := parseTracearrTime(session.StoppedAt); ok {
|
||||
return stopped, true
|
||||
}
|
||||
return parseTracearrTime(session.StartedAt)
|
||||
}
|
||||
|
||||
func abandonedShowReason(lastSeason, nextSeason int) string {
|
||||
switch {
|
||||
case lastSeason == 1 && nextSeason > 1:
|
||||
return fmt.Sprintf("You finished season 1 · season %d is waiting", nextSeason)
|
||||
case lastSeason > 1 && nextSeason > lastSeason:
|
||||
return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason)
|
||||
case lastSeason > 1:
|
||||
return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason)
|
||||
case lastSeason == 1:
|
||||
return "You left this in season 1 · pick it up again"
|
||||
default:
|
||||
return "You left this unfinished · pick it up again"
|
||||
}
|
||||
}
|
||||
|
||||
func compatibilityLabelForScore(score float64) string {
|
||||
switch {
|
||||
case score > 0.2:
|
||||
return "Direct plays well on this TV"
|
||||
case score < -0.2:
|
||||
return "May need transcoding on this TV"
|
||||
default:
|
||||
return "TV compatibility not yet learned"
|
||||
}
|
||||
}
|
||||
|
||||
func explainPreparedRecommendation(
|
||||
profile Profile,
|
||||
item Item,
|
||||
compatibility compatibilityProfile,
|
||||
browsed bool,
|
||||
evidenceByGenre map[string][]PreparedEvidence,
|
||||
completedReasonCounts map[string]int,
|
||||
) (reason, label, kind, genre string, evidence PreparedEvidence) {
|
||||
for _, wanted := range profile.TopGenres(5) {
|
||||
for _, candidateGenre := range item.Genres {
|
||||
if strings.EqualFold(wanted, candidateGenre) {
|
||||
genre = candidateGenre
|
||||
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
|
||||
strong := make([]PreparedEvidence, 0, len(options))
|
||||
for _, option := range options {
|
||||
if strongEvidenceMatch(item, option) {
|
||||
strong = append(strong, option)
|
||||
}
|
||||
}
|
||||
if len(strong) > 0 {
|
||||
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if genre != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Keep specific evidence prominent without letting it monopolise a row. One third
|
||||
// of otherwise eligible cards deliberately uses the broader genre explanation,
|
||||
// and no completed title can explain more than four candidates in a prepared pool.
|
||||
useCompleted := evidence.Title != "" &&
|
||||
stableEvidenceIndex("reason-kind:"+item.ID, 3) != 0 &&
|
||||
completedReasonCounts[evidence.ItemID] < 4
|
||||
switch {
|
||||
case browsed:
|
||||
reason, kind = "You explored this recently", "browsed"
|
||||
evidence = PreparedEvidence{}
|
||||
case useCompleted:
|
||||
reason, kind = "Because you finished "+evidence.Title, "completed-title"
|
||||
completedReasonCounts[evidence.ItemID]++
|
||||
case genre != "":
|
||||
reason, kind = "Matches your "+genre+" viewing", "genre"
|
||||
evidence = PreparedEvidence{}
|
||||
case len(profile.Seeds) > 0:
|
||||
reason, kind = "Inspired by "+profile.Seeds[0].Name, "recent-title"
|
||||
evidence = PreparedEvidence{}
|
||||
default:
|
||||
reason, kind = "Matches your recent viewing", "generic"
|
||||
evidence = PreparedEvidence{}
|
||||
}
|
||||
switch score := compatibilityScore(item, compatibility); {
|
||||
case score > 0.2:
|
||||
label = "Direct plays well on this TV"
|
||||
reason += " · " + label
|
||||
case score < -0.2:
|
||||
label = "May need transcoding on this TV"
|
||||
default:
|
||||
label = "TV compatibility not yet learned"
|
||||
}
|
||||
return reason, label, kind, genre, evidence
|
||||
}
|
||||
|
||||
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
|
||||
shared := 0
|
||||
broadOnly := true
|
||||
for _, candidateGenre := range item.Genres {
|
||||
for _, evidenceGenre := range evidence.Genres {
|
||||
if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
|
||||
continue
|
||||
}
|
||||
shared++
|
||||
switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
|
||||
case "action", "adventure", "comedy", "drama", "thriller":
|
||||
default:
|
||||
broadOnly = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return shared >= 2 || shared == 1 && !broadOnly
|
||||
}
|
||||
|
||||
func stableEvidenceIndex(itemID string, size int) int {
|
||||
if size <= 1 {
|
||||
return 0
|
||||
}
|
||||
var hash uint32 = 2166136261
|
||||
for _, value := range []byte(itemID) {
|
||||
hash ^= uint32(value)
|
||||
hash *= 16777619
|
||||
}
|
||||
return int(hash % uint32(size))
|
||||
}
|
||||
|
||||
type catalogueIndex struct {
|
||||
movieExact map[string]Item
|
||||
movieLoose map[string]Item
|
||||
series map[string]Item
|
||||
ambiguous map[string]bool
|
||||
}
|
||||
|
||||
func newCatalogueIndex(items []Item) catalogueIndex {
|
||||
index := catalogueIndex{
|
||||
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
|
||||
series: map[string]Item{}, ambiguous: map[string]bool{},
|
||||
}
|
||||
for _, item := range items {
|
||||
key := normalizePreparedTitle(item.Name)
|
||||
switch item.Type {
|
||||
case "Movie":
|
||||
if item.ProductionYear > 0 {
|
||||
index.movieExact[key+"|"+itoa(item.ProductionYear)] = item
|
||||
}
|
||||
if _, exists := index.movieLoose[key]; exists {
|
||||
index.ambiguous["movie|"+key] = true
|
||||
} else {
|
||||
index.movieLoose[key] = item
|
||||
}
|
||||
case "Series":
|
||||
if _, exists := index.series[key]; exists {
|
||||
index.ambiguous["series|"+key] = true
|
||||
} else {
|
||||
index.series[key] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (i catalogueIndex) match(session tracearr.Session) (Item, bool) {
|
||||
if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" {
|
||||
key := normalizePreparedTitle(session.ShowTitle)
|
||||
if i.ambiguous["series|"+key] {
|
||||
return Item{}, false
|
||||
}
|
||||
item, ok := i.series[key]
|
||||
return item, ok
|
||||
}
|
||||
key := normalizePreparedTitle(session.MediaTitle)
|
||||
if session.Year != nil && *session.Year > 0 {
|
||||
if item, ok := i.movieExact[key+"|"+itoa(*session.Year)]; ok {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
if i.ambiguous["movie|"+key] {
|
||||
return Item{}, false
|
||||
}
|
||||
item, ok := i.movieLoose[key]
|
||||
return item, ok
|
||||
}
|
||||
|
||||
func normalizePreparedTitle(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(value) {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseTracearrTime(value string) (time.Time, bool) {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed.UTC(), true
|
||||
}
|
||||
|
||||
func medianInt(values []int) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
copyValues := append([]int(nil), values...)
|
||||
sort.Ints(copyValues)
|
||||
mid := len(copyValues) / 2
|
||||
if len(copyValues)%2 == 1 {
|
||||
return copyValues[mid]
|
||||
}
|
||||
return (copyValues[mid-1] + copyValues[mid]) / 2
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for value > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package sonarr provides the small read-only slice of Sonarr used by the home screen.
|
||||
package sonarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
CoverType string `json:"coverType"`
|
||||
URL string `json:"url"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Year int `json:"year"`
|
||||
Network string `json:"network"`
|
||||
Genres []string `json:"genres"`
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
|
||||
type EpisodeFile struct {
|
||||
DateAdded *time.Time `json:"dateAdded"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
ID int `json:"id"`
|
||||
SeriesID int `json:"seriesId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
AirDateUTC *time.Time `json:"airDateUtc"`
|
||||
Runtime int `json:"runtime"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
Monitored bool `json:"monitored"`
|
||||
Grabbed bool `json:"grabbed"`
|
||||
Series Series `json:"series"`
|
||||
EpisodeFile *EpisodeFile `json:"episodeFile"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("sonarr: status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func New(baseURL, apiKey string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar returns episodes in [start, end), including series artwork and imported-file
|
||||
// details so Memby can distinguish upcoming, downloading and already-added episodes.
|
||||
func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Episode, error) {
|
||||
params := url.Values{
|
||||
"start": {start.UTC().Format(time.RFC3339Nano)},
|
||||
"end": {end.UTC().Format(time.RFC3339Nano)},
|
||||
"unmonitored": {"true"},
|
||||
"includeSeries": {"true"},
|
||||
"includeEpisodeFile": {"true"},
|
||||
"includeEpisodeImages": {"true"},
|
||||
}
|
||||
req, err := c.request(ctx, "/api/v3/calendar", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var episodes []Episode
|
||||
if err := c.do(req, &episodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
// MediaCover fetches a series poster or fanart without exposing the Sonarr API key.
|
||||
func (c *Client) MediaCover(ctx context.Context, seriesID int, coverType string) (*http.Response, error) {
|
||||
if seriesID <= 0 || (coverType != "poster" && coverType != "fanart") {
|
||||
return nil, fmt.Errorf("sonarr: invalid media cover")
|
||||
}
|
||||
path := "/MediaCover/" + strconv.Itoa(seriesID) + "/" + coverType + ".jpg"
|
||||
req, err := c.request(ctx, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "image/*")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sonarr: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, path string, params url.Values) (*http.Request, error) {
|
||||
endpoint := c.baseURL + path
|
||||
if len(params) > 0 {
|
||||
endpoint += "?" + params.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sonarr: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("sonarr: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package sonarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
|
||||
var gotQuery string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v3/calendar" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q", got)
|
||||
}
|
||||
if r.URL.Query().Get("includeSeries") != "true" ||
|
||||
r.URL.Query().Get("includeEpisodeFile") != "true" {
|
||||
t.Errorf("missing include flags: %s", r.URL.RawQuery)
|
||||
}
|
||||
gotQuery = r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":7,"seriesId":2,"title":"Arrival","hasFile":true}]`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
client := New(upstream.URL, "secret", time.Second)
|
||||
episodes, err := client.Calendar(
|
||||
context.Background(),
|
||||
time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(episodes) != 1 || episodes[0].ID != 7 || !episodes[0].HasFile {
|
||||
t.Fatalf("unexpected episodes: %+v", episodes)
|
||||
}
|
||||
if gotQuery == "" || rHasAPIKey(gotQuery) {
|
||||
t.Fatalf("API key leaked into query: %q", gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func rHasAPIKey(query string) bool {
|
||||
return strings.Contains(query, "secret")
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const tracearrImportStateKey = "tracearr_import_state"
|
||||
|
||||
type TracearrSessionKey struct {
|
||||
ServerID string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
type TracearrSession struct {
|
||||
ServerID string
|
||||
SessionID string
|
||||
UserID string
|
||||
Username string
|
||||
State string
|
||||
MediaType string
|
||||
MediaTitle string
|
||||
ShowTitle string
|
||||
SeasonNumber *int
|
||||
EpisodeNumber *int
|
||||
ProductionYear *int
|
||||
StartedAt *time.Time
|
||||
StoppedAt *time.Time
|
||||
DurationMs int64
|
||||
ProgressMs int64
|
||||
TotalDurationMs int64
|
||||
Watched bool
|
||||
Device string
|
||||
Player string
|
||||
Product string
|
||||
Platform string
|
||||
IsTranscode bool
|
||||
VideoDecision string
|
||||
AudioDecision string
|
||||
SourceVideoCodec string
|
||||
SourceAudioCodec string
|
||||
EmbyItemID string
|
||||
EmbySeriesID string
|
||||
SourceFingerprint []byte
|
||||
}
|
||||
|
||||
type TracearrImportState struct {
|
||||
LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
|
||||
LastFullAt *time.Time `json:"lastFullAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type RecommendationProfile struct {
|
||||
EmbyUserID string
|
||||
TracearrUserID string
|
||||
TracearrUsername string
|
||||
SourceSessionCount int
|
||||
MeanCompletionRatio float64
|
||||
TypicalSessionMinutes int
|
||||
GenreAffinity json.RawMessage
|
||||
TitleAffinity json.RawMessage
|
||||
StudioAffinity json.RawMessage
|
||||
CodecOutcomes json.RawMessage
|
||||
SignalsThrough *time.Time
|
||||
BuiltAt time.Time
|
||||
}
|
||||
|
||||
type ForYouCandidate struct {
|
||||
ItemID string
|
||||
BaseRank int
|
||||
BaseScore float64
|
||||
RuntimeMinutes int
|
||||
AffinityScore float64
|
||||
CompatibilityScore float64
|
||||
CompatibilityLabel string
|
||||
ReasonKind string
|
||||
ReasonGenre string
|
||||
ReasonSourceSessionID string
|
||||
ReasonSourceItemID string
|
||||
ReasonSourceTitle string
|
||||
RecommendationReason string
|
||||
}
|
||||
|
||||
type PreparedForYouItem struct {
|
||||
ItemID string
|
||||
BaseRank int
|
||||
Payload json.RawMessage
|
||||
RuntimeMinutes int
|
||||
CompatibilityScore float64
|
||||
CompatibilityLabel string
|
||||
RecommendationReason string
|
||||
ReasonKind string
|
||||
ReasonGenre string
|
||||
ReasonSourceItemID string
|
||||
ReasonSourceTitle string
|
||||
}
|
||||
|
||||
type ForYouStats struct {
|
||||
TracearrSessions int64 `json:"tracearrSessions"`
|
||||
Profiles int64 `json:"profiles"`
|
||||
Candidates int64 `json:"candidates"`
|
||||
LastFullImport *time.Time `json:"lastFullImport,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) TracearrSessionCount(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM tracearr_sessions`).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("store: count tracearr sessions: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Store) TracearrFingerprints(
|
||||
ctx context.Context,
|
||||
keys []TracearrSessionKey,
|
||||
) (map[TracearrSessionKey][]byte, error) {
|
||||
out := make(map[TracearrSessionKey][]byte, len(keys))
|
||||
if len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
servers := make([]string, 0, len(keys))
|
||||
ids := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
servers = append(servers, key.ServerID)
|
||||
ids = append(ids, key.SessionID)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint
|
||||
FROM tracearr_sessions current
|
||||
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
|
||||
ON current.server_id = wanted.server_id
|
||||
AND current.tracearr_session_id = wanted.session_id`,
|
||||
servers, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: tracearr fingerprints: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var key TracearrSessionKey
|
||||
var fingerprint []byte
|
||||
if err := rows.Scan(&key.ServerID, &key.SessionID, &fingerprint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = fingerprint
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertTracearrSessions(
|
||||
ctx context.Context,
|
||||
sessions []TracearrSession,
|
||||
seenAt time.Time,
|
||||
) error {
|
||||
if len(sessions) == 0 {
|
||||
return nil
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for _, session := range sessions {
|
||||
batch.Queue(`
|
||||
INSERT INTO tracearr_sessions (
|
||||
server_id, tracearr_session_id, tracearr_user_id, username, state,
|
||||
media_type, media_title, show_title, season_number, episode_number,
|
||||
production_year, started_at, stopped_at, duration_ms, progress_ms,
|
||||
total_duration_ms, watched, device, player, product, platform,
|
||||
is_transcode, video_decision, audio_decision, source_video_codec,
|
||||
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint,
|
||||
source_seen_at, imported_at, updated_at
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
|
||||
$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,now(),now()
|
||||
)
|
||||
ON CONFLICT (server_id, tracearr_session_id) DO UPDATE SET
|
||||
tracearr_user_id = EXCLUDED.tracearr_user_id,
|
||||
username = EXCLUDED.username,
|
||||
state = EXCLUDED.state,
|
||||
media_type = EXCLUDED.media_type,
|
||||
media_title = EXCLUDED.media_title,
|
||||
show_title = EXCLUDED.show_title,
|
||||
season_number = EXCLUDED.season_number,
|
||||
episode_number = EXCLUDED.episode_number,
|
||||
production_year = EXCLUDED.production_year,
|
||||
started_at = EXCLUDED.started_at,
|
||||
stopped_at = EXCLUDED.stopped_at,
|
||||
duration_ms = EXCLUDED.duration_ms,
|
||||
progress_ms = EXCLUDED.progress_ms,
|
||||
total_duration_ms = EXCLUDED.total_duration_ms,
|
||||
watched = EXCLUDED.watched,
|
||||
device = EXCLUDED.device,
|
||||
player = EXCLUDED.player,
|
||||
product = EXCLUDED.product,
|
||||
platform = EXCLUDED.platform,
|
||||
is_transcode = EXCLUDED.is_transcode,
|
||||
video_decision = EXCLUDED.video_decision,
|
||||
audio_decision = EXCLUDED.audio_decision,
|
||||
source_video_codec = EXCLUDED.source_video_codec,
|
||||
source_audio_codec = EXCLUDED.source_audio_codec,
|
||||
source_fingerprint = EXCLUDED.source_fingerprint,
|
||||
source_seen_at = EXCLUDED.source_seen_at,
|
||||
updated_at = CASE
|
||||
WHEN tracearr_sessions.source_fingerprint IS DISTINCT FROM EXCLUDED.source_fingerprint
|
||||
THEN now() ELSE tracearr_sessions.updated_at END`,
|
||||
session.ServerID, session.SessionID, session.UserID, session.Username,
|
||||
session.State, session.MediaType, session.MediaTitle, session.ShowTitle,
|
||||
session.SeasonNumber, session.EpisodeNumber, session.ProductionYear,
|
||||
session.StartedAt, session.StoppedAt, session.DurationMs, session.ProgressMs,
|
||||
session.TotalDurationMs, session.Watched, session.Device, session.Player,
|
||||
session.Product, session.Platform, session.IsTranscode, session.VideoDecision,
|
||||
session.AudioDecision, session.SourceVideoCodec, session.SourceAudioCodec,
|
||||
session.EmbyItemID, session.EmbySeriesID, session.SourceFingerprint, seenAt)
|
||||
}
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
for range sessions {
|
||||
if _, err := results.Exec(); err != nil {
|
||||
return fmt.Errorf("store: upsert tracearr sessions: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTracearrSessionsNotSeenSince(
|
||||
ctx context.Context,
|
||||
serverID string,
|
||||
cutoff time.Time,
|
||||
) (int64, error) {
|
||||
if serverID == "" {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM tracearr_sessions WHERE source_seen_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM tracearr_sessions WHERE server_id = $1 AND source_seen_at < $2`,
|
||||
serverID, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *Store) TracearrSessionsForUser(
|
||||
ctx context.Context,
|
||||
tracearrUserID, username string,
|
||||
) ([]TracearrSession, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT server_id, tracearr_session_id, tracearr_user_id, username, state,
|
||||
media_type, media_title, show_title, season_number, episode_number,
|
||||
production_year, started_at, stopped_at, duration_ms, progress_ms,
|
||||
total_duration_ms, watched, device, player, product, platform,
|
||||
is_transcode, video_decision, audio_decision, source_video_codec,
|
||||
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint
|
||||
FROM tracearr_sessions
|
||||
WHERE ($1 <> '' AND tracearr_user_id = $1)
|
||||
OR ($1 = '' AND lower(username) = lower($2))
|
||||
ORDER BY started_at DESC NULLS LAST, tracearr_session_id DESC`,
|
||||
tracearrUserID, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: tracearr user sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TracearrSession{}
|
||||
for rows.Next() {
|
||||
var session TracearrSession
|
||||
if err := rows.Scan(
|
||||
&session.ServerID, &session.SessionID, &session.UserID, &session.Username,
|
||||
&session.State, &session.MediaType, &session.MediaTitle, &session.ShowTitle,
|
||||
&session.SeasonNumber, &session.EpisodeNumber, &session.ProductionYear,
|
||||
&session.StartedAt, &session.StoppedAt, &session.DurationMs, &session.ProgressMs,
|
||||
&session.TotalDurationMs, &session.Watched, &session.Device, &session.Player,
|
||||
&session.Product, &session.Platform, &session.IsTranscode,
|
||||
&session.VideoDecision, &session.AudioDecision, &session.SourceVideoCodec,
|
||||
&session.SourceAudioCodec, &session.EmbyItemID, &session.EmbySeriesID,
|
||||
&session.SourceFingerprint,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, session)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTracearrSessionMapping(
|
||||
ctx context.Context,
|
||||
key TracearrSessionKey,
|
||||
itemID, seriesID string,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE tracearr_sessions
|
||||
SET emby_item_id = $3, emby_series_id = $4
|
||||
WHERE server_id = $1 AND tracearr_session_id = $2
|
||||
AND (emby_item_id, emby_series_id) IS DISTINCT FROM ($3, $4)`,
|
||||
key.ServerID, key.SessionID, itemID, seriesID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: map tracearr session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ForYouTracearrIdentity(ctx context.Context, userID string) (string, string, error) {
|
||||
var tracearrUserID, username string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT tracearr_user_id, tracearr_username
|
||||
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
|
||||
Scan(&tracearrUserID, &username)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("store: For You Tracearr identity: %w", err)
|
||||
}
|
||||
return tracearrUserID, username, nil
|
||||
}
|
||||
|
||||
func (s *Store) TracearrImportState(ctx context.Context) (TracearrImportState, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT value FROM app_settings WHERE key = $1`, tracearrImportStateKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return TracearrImportState{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return TracearrImportState{}, fmt.Errorf("store: read tracearr import state: %w", err)
|
||||
}
|
||||
var state TracearrImportState
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return state, fmt.Errorf("store: decode tracearr import state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImportState) error {
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
tracearrImportStateKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write tracearr import state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (emby_user_id)
|
||||
token_hash, emby_user_id, emby_token, username, server_id, device_id,
|
||||
device_name, client_version, client_protocol, last_seen_at
|
||||
FROM sessions
|
||||
ORDER BY emby_user_id, last_seen_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: active recommendation users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Session{}
|
||||
for rows.Next() {
|
||||
var session Session
|
||||
if err := rows.Scan(
|
||||
&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
|
||||
&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
|
||||
&session.ClientProtocol, &session.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, session)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) MarkForYouDirty(ctx context.Context, userID, username string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO recommendation_user_profiles (emby_user_id, tracearr_username, dirty_since)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
tracearr_username = CASE WHEN $2 <> '' THEN $2 ELSE recommendation_user_profiles.tracearr_username END,
|
||||
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
|
||||
userID, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: mark For You dirty: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE recommendation_user_profiles
|
||||
SET dirty_since = coalesce(dirty_since, now())`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: mark all For You profiles dirty: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MatchRecommendationUser(
|
||||
ctx context.Context,
|
||||
embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO recommendation_user_profiles (
|
||||
emby_user_id, tracearr_user_id, tracearr_username, dirty_since
|
||||
) VALUES ($1, $2, CASE WHEN $3 <> '' THEN $3 ELSE $4 END, now())
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
tracearr_user_id = EXCLUDED.tracearr_user_id,
|
||||
tracearr_username = CASE
|
||||
WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username
|
||||
ELSE $4
|
||||
END,
|
||||
dirty_since = CASE
|
||||
WHEN recommendation_user_profiles.tracearr_user_id IS DISTINCT FROM EXCLUDED.tracearr_user_id
|
||||
OR recommendation_user_profiles.tracearr_username IS DISTINCT FROM
|
||||
CASE WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username ELSE $4 END
|
||||
THEN coalesce(recommendation_user_profiles.dirty_since, now())
|
||||
ELSE recommendation_user_profiles.dirty_since
|
||||
END`,
|
||||
embyUserID, tracearrUserID, tracearrUsername, embyUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: match recommendation user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ForYouProfileTimes(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) (builtAt, poolBuiltAt, dirtySince *time.Time, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT built_at, pool_built_at, dirty_since
|
||||
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
|
||||
Scan(&builtAt, &poolBuiltAt, &dirtySince)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("store: For You freshness: %w", err)
|
||||
}
|
||||
return builtAt, poolBuiltAt, dirtySince, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
|
||||
message := ""
|
||||
if buildErr != nil {
|
||||
message = buildErr.Error()
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO recommendation_user_profiles (emby_user_id, last_error, dirty_since)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET last_error = $2,
|
||||
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
|
||||
userID, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceForYouPool(
|
||||
ctx context.Context,
|
||||
profile RecommendationProfile,
|
||||
candidates []ForYouCandidate,
|
||||
) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin For You rebuild: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO recommendation_user_profiles (
|
||||
emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
|
||||
mean_completion_ratio, typical_session_minutes, genre_affinity,
|
||||
title_affinity, studio_affinity, codec_outcomes, signals_through,
|
||||
built_at, pool_built_at, dirty_since, last_error
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11,$12,$12,NULL,''
|
||||
)
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
tracearr_user_id = EXCLUDED.tracearr_user_id,
|
||||
tracearr_username = EXCLUDED.tracearr_username,
|
||||
source_session_count = EXCLUDED.source_session_count,
|
||||
mean_completion_ratio = EXCLUDED.mean_completion_ratio,
|
||||
typical_session_minutes = EXCLUDED.typical_session_minutes,
|
||||
genre_affinity = EXCLUDED.genre_affinity,
|
||||
title_affinity = EXCLUDED.title_affinity,
|
||||
studio_affinity = EXCLUDED.studio_affinity,
|
||||
codec_outcomes = EXCLUDED.codec_outcomes,
|
||||
signals_through = EXCLUDED.signals_through,
|
||||
built_at = EXCLUDED.built_at,
|
||||
pool_built_at = EXCLUDED.pool_built_at,
|
||||
dirty_since = NULL,
|
||||
last_error = ''`,
|
||||
profile.EmbyUserID, profile.TracearrUserID, profile.TracearrUsername,
|
||||
profile.SourceSessionCount, profile.MeanCompletionRatio,
|
||||
profile.TypicalSessionMinutes, string(profile.GenreAffinity),
|
||||
string(profile.TitleAffinity), string(profile.StudioAffinity),
|
||||
string(profile.CodecOutcomes), profile.SignalsThrough, profile.BuiltAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write For You profile: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM for_you_candidates WHERE emby_user_id = $1`, profile.EmbyUserID); err != nil {
|
||||
return fmt.Errorf("store: clear For You pool: %w", err)
|
||||
}
|
||||
|
||||
batch := &pgx.Batch{}
|
||||
for _, candidate := range candidates {
|
||||
batch.Queue(`
|
||||
INSERT INTO for_you_candidates (
|
||||
emby_user_id, item_id, base_rank, base_score, runtime_minutes,
|
||||
affinity_score, compatibility_score, compatibility_label, reason_kind,
|
||||
reason_genre, reason_source_session_id, reason_source_item_id,
|
||||
reason_source_title, recommendation_reason, built_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
|
||||
profile.EmbyUserID, candidate.ItemID, candidate.BaseRank, candidate.BaseScore,
|
||||
candidate.RuntimeMinutes, candidate.AffinityScore, candidate.CompatibilityScore,
|
||||
candidate.CompatibilityLabel, candidate.ReasonKind, candidate.ReasonGenre,
|
||||
candidate.ReasonSourceSessionID, candidate.ReasonSourceItemID,
|
||||
candidate.ReasonSourceTitle, candidate.RecommendationReason, profile.BuiltAt)
|
||||
}
|
||||
results := tx.SendBatch(ctx, batch)
|
||||
for range candidates {
|
||||
if _, err := results.Exec(); err != nil {
|
||||
_ = results.Close()
|
||||
return fmt.Errorf("store: insert For You candidates: %w", err)
|
||||
}
|
||||
}
|
||||
if err := results.Close(); err != nil {
|
||||
return fmt.Errorf("store: close For You candidate batch: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("store: commit For You rebuild: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) PreparedForYou(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
minutes, limit int,
|
||||
) ([]PreparedForYouItem, *time.Time, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT fc.item_id, fc.base_rank, li.payload, fc.runtime_minutes,
|
||||
fc.compatibility_score, fc.compatibility_label,
|
||||
fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
|
||||
fc.reason_source_item_id, fc.reason_source_title, p.pool_built_at
|
||||
FROM for_you_candidates fc
|
||||
JOIN library_items li ON li.id = fc.item_id
|
||||
JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
|
||||
WHERE fc.emby_user_id = $1
|
||||
AND ($2 = 0 OR (fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2))
|
||||
ORDER BY fc.base_rank
|
||||
LIMIT $3`,
|
||||
userID, minutes, limit)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("store: prepared For You: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []PreparedForYouItem{}
|
||||
var builtAt *time.Time
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
var item PreparedForYouItem
|
||||
var rowBuiltAt *time.Time
|
||||
if err := rows.Scan(
|
||||
&item.ItemID, &item.BaseRank, &payload, &item.RuntimeMinutes,
|
||||
&item.CompatibilityScore, &item.CompatibilityLabel,
|
||||
&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
|
||||
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &rowBuiltAt,
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
item.Payload = json.RawMessage(payload)
|
||||
out = append(out, item)
|
||||
if builtAt == nil {
|
||||
builtAt = rowBuiltAt
|
||||
}
|
||||
}
|
||||
return out, builtAt, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ForYouStats(ctx context.Context) (ForYouStats, error) {
|
||||
var stats ForYouStats
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT count(*) FROM tracearr_sessions),
|
||||
(SELECT count(*) FROM recommendation_user_profiles),
|
||||
(SELECT count(*) FROM for_you_candidates)`).
|
||||
Scan(&stats.TracearrSessions, &stats.Profiles, &stats.Candidates); err != nil {
|
||||
return stats, fmt.Errorf("store: For You stats: %w", err)
|
||||
}
|
||||
state, err := s.TracearrImportState(ctx)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.LastFullImport = state.LastFullAt
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// Package tracearr reads per-user playback history from Tracearr's public API.
|
||||
//
|
||||
// Tracearr is deliberately kept behind the Memby gateway: its operator API key never
|
||||
// reaches a television, and a Tracearr outage can only reduce recommendation quality.
|
||||
package tracearr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
serverID string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"serverId"`
|
||||
State string `json:"state"`
|
||||
MediaType string `json:"mediaType"`
|
||||
MediaTitle string `json:"mediaTitle"`
|
||||
ShowTitle string `json:"showTitle"`
|
||||
SeasonNumber *int `json:"seasonNumber"`
|
||||
EpisodeNumber *int `json:"episodeNumber"`
|
||||
Year *int `json:"year"`
|
||||
DurationMs FlexibleInt64 `json:"durationMs"`
|
||||
ProgressMs FlexibleInt64 `json:"progressMs"`
|
||||
TotalDurationMs FlexibleInt64 `json:"totalDurationMs"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
StoppedAt string `json:"stoppedAt"`
|
||||
Watched bool `json:"watched"`
|
||||
Device string `json:"device"`
|
||||
Player string `json:"player"`
|
||||
Product string `json:"product"`
|
||||
Platform string `json:"platform"`
|
||||
IsTranscode bool `json:"isTranscode"`
|
||||
VideoDecision string `json:"videoDecision"`
|
||||
AudioDecision string `json:"audioDecision"`
|
||||
SourceVideoCodec string `json:"sourceVideoCodec"`
|
||||
SourceAudioCodec string `json:"sourceAudioCodec"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
// FlexibleInt64 accepts both JSON numbers and quoted integers. Tracearr currently
|
||||
// serialises progress fields as strings while duration is numeric.
|
||||
type FlexibleInt64 int64
|
||||
|
||||
func (v *FlexibleInt64) UnmarshalJSON(raw []byte) error {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if bytes.Equal(raw, []byte("null")) || len(raw) == 0 {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tracearr integer %q: %w", value, err)
|
||||
}
|
||||
*v = FlexibleInt64(parsed)
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.ParseInt(string(raw), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tracearr integer %q: %w", string(raw), err)
|
||||
}
|
||||
*v = FlexibleInt64(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Data []Session `json:"data"`
|
||||
Meta struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
SessionCount int `json:"sessionCount"`
|
||||
}
|
||||
|
||||
type UserPage struct {
|
||||
Data []User `json:"data"`
|
||||
Meta struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
func New(baseURL, apiKey, serverID string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
serverID: strings.TrimSpace(serverID),
|
||||
http: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// History returns the newest sessions belonging to username. Tracearr's public history
|
||||
// endpoint currently has no user filter, so Memby pages through the bounded recent
|
||||
// window and filters locally. Usernames are the stable identity shared by Emby and
|
||||
// Tracearr; no fuzzy matching is used.
|
||||
func (c *Client) History(ctx context.Context, username string, limit int) ([]Session, error) {
|
||||
if c == nil || c.baseURL == "" || c.apiKey == "" || strings.TrimSpace(username) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
const pageSize = 100
|
||||
const maxPages = 10
|
||||
|
||||
wanted := strings.TrimSpace(username)
|
||||
out := make([]Session, 0, min(limit, pageSize))
|
||||
for pageNumber := 1; pageNumber <= maxPages && len(out) < limit; pageNumber++ {
|
||||
historyPage, err := c.Page(ctx, pageNumber, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range historyPage.Data {
|
||||
if strings.EqualFold(strings.TrimSpace(session.User.Username), wanted) {
|
||||
out = append(out, session)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if pageNumber*pageSize >= historyPage.Meta.Total || len(historyPage.Data) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Page reads one public-history page. Imports use this directly so Tracearr pagination
|
||||
// happens in the background rather than while a television waits.
|
||||
func (c *Client) Page(ctx context.Context, pageNumber, pageSize int) (Page, error) {
|
||||
endpoint, err := c.publicEndpoint("/api/v1/public/history", pageNumber, pageSize)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("tracearr history: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return Page{}, fmt.Errorf("tracearr history: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var result Page
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&result); err != nil {
|
||||
return Page{}, fmt.Errorf("tracearr history: decode: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Users lists the Tracearr identities known to the configured media server. Household
|
||||
// preparation uses exact username matches against Emby; no fuzzy identity guesses are
|
||||
// made here.
|
||||
func (c *Client) Users(ctx context.Context, pageNumber, pageSize int) (UserPage, error) {
|
||||
endpoint, err := c.publicEndpoint("/api/v1/public/users", pageNumber, pageSize)
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return UserPage{}, fmt.Errorf("tracearr users: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return UserPage{}, fmt.Errorf(
|
||||
"tracearr users: status %d: %s",
|
||||
resp.StatusCode, strings.TrimSpace(string(body)),
|
||||
)
|
||||
}
|
||||
var result UserPage
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
||||
return UserPage{}, fmt.Errorf("tracearr users: decode: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) publicEndpoint(path string, pageNumber, pageSize int) (*url.URL, error) {
|
||||
endpoint, err := url.Parse(c.baseURL + path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("page", strconv.Itoa(pageNumber))
|
||||
query.Set("pageSize", strconv.Itoa(pageSize))
|
||||
if c.serverID != "" {
|
||||
query.Set("serverId", c.serverID)
|
||||
}
|
||||
endpoint.RawQuery = query.Encode()
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
func (c *Client) ConfiguredServerID() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.serverID
|
||||
}
|
||||
|
||||
// Completion is the useful fraction of a session. Tracearr's watched flag wins; for an
|
||||
// interrupted play, progress is preferred and aggregate watch time is the fallback.
|
||||
func (s Session) Completion() float64 {
|
||||
if s.Watched {
|
||||
return 1
|
||||
}
|
||||
if s.TotalDurationMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
progress := s.ProgressMs
|
||||
if s.DurationMs > progress {
|
||||
progress = s.DurationMs
|
||||
}
|
||||
value := float64(progress) / float64(s.TotalDurationMs)
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s Session) TitleKey() string {
|
||||
if strings.EqualFold(s.MediaType, "episode") && strings.TrimSpace(s.ShowTitle) != "" {
|
||||
return normalizeTitle(s.ShowTitle)
|
||||
}
|
||||
return normalizeTitle(s.MediaTitle)
|
||||
}
|
||||
|
||||
func (s Session) IsTelevisionSession() bool {
|
||||
value := strings.ToLower(strings.Join([]string{s.Device, s.Player, s.Product, s.Platform}, " "))
|
||||
return strings.Contains(value, "tv") ||
|
||||
strings.Contains(value, "android") ||
|
||||
strings.Contains(value, "memby")
|
||||
}
|
||||
|
||||
func normalizeTitle(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(value) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package tracearr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFlexiblePlaybackNumbersAcceptTracearrStrings(t *testing.T) {
|
||||
var session Session
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"durationMs":1234,
|
||||
"progressMs":"900",
|
||||
"totalDurationMs":"1800"
|
||||
}`), &session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.DurationMs != 1234 || session.ProgressMs != 900 || session.TotalDurationMs != 1800 {
|
||||
t.Fatalf("unexpected playback values: %+v", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryFiltersExactUserAndSendsBearerToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer trr_pub_secret" {
|
||||
t.Fatalf("authorization = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("serverId"); got != "server-1" {
|
||||
t.Fatalf("serverId = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"data": [
|
||||
{"mediaTitle":"Arrival","mediaType":"movie","watched":true,"user":{"id":"1","username":"Matt"}},
|
||||
{"mediaTitle":"Alien","mediaType":"movie","watched":true,"user":{"id":"2","username":"Other"}}
|
||||
],
|
||||
"meta":{"total":2,"page":1,"pageSize":100}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(server.URL, "trr_pub_secret", "server-1", time.Second)
|
||||
history, err := client.History(context.Background(), "matt", 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 1 || history[0].MediaTitle != "Arrival" {
|
||||
t.Fatalf("history = %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionAndTelevisionDetection(t *testing.T) {
|
||||
session := Session{ProgressMs: 45, TotalDurationMs: 100, Platform: "Android TV"}
|
||||
if got := session.Completion(); got != .45 {
|
||||
t.Fatalf("completion = %v", got)
|
||||
}
|
||||
if !session.IsTelevisionSession() {
|
||||
t.Fatal("expected Android TV session")
|
||||
}
|
||||
session.Watched = true
|
||||
if got := session.Completion(); got != 1 {
|
||||
t.Fatalf("watched completion = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersUsesPublicUsersEndpointAndServerScope(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/public/users" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("serverId"); got != "server-1" {
|
||||
t.Fatalf("serverId = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"data":[{"id":"trace-user","username":"Matt","sessionCount":385}],
|
||||
"meta":{"total":1,"page":1,"pageSize":100}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
page, err := New(server.URL, "secret", "server-1", time.Second).
|
||||
Users(context.Background(), 1, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Data) != 1 || page.Data[0].Username != "Matt" ||
|
||||
page.Data[0].SessionCount != 385 {
|
||||
t.Fatalf("users = %+v", page.Data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user