diff --git a/AGENTS.md b/AGENTS.md index 44c2a602..7aa76636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,30 @@ it), the worst case is the user gets a "Up to date" message and needs to manually hit Download. `just apk-push` is that script; don't upload the files by hand. +### Profiles and friends + +A profile is a `users` row with `account_id` set, so every per-user table works +unchanged; never add a `profile_id`. Sign-in and the admin list see accounts +only (`account_id IS NULL`). Switching (`POST /api/auth/switch-profile`, under +`/api/auth` for the refresh cookie's path) opens a session on the other row. What +touches an account touches its profiles: `users::set_password`, `users::delete`, +`SessionCuts::forget` (clears everyone), the device list. Friendships hang on +profiles. What a profile adds is visible to friends; what it watches, live +included, only with `share_watching`, and `friends_hidden` beats both. A declined +request keeps its row: the asker still reads it as pending. + +A person's mascot comes from their name alone: the Iris mark's grammar (no mouth), +its table and hash in `packages/iris-api/src/mascot.ts` and again in the TV's +`ui/components/Mascot.kt`. Changing one means changing both, and the test vectors +they share. + +### The update sentence + +A release that users should hear about gets one `> sentence` right under its +`## [x.y.z]` heading in `CHANGELOG.md`, in English. The server embeds it at +build time (`routes/release_note.rs`) and the web and TV homes say it once. No +quote line, nothing said: fine for a quiet patch. + ### Versioning & client gate **All three components** carry the same semver-aligned version diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b81836c..14b7c065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **What changed, in a sentence**: after an update the home says it once, on + the web and the TV. The sentence is a `> quote` line right under the version + heading here; a release without one says nothing. +- **Profiles**: a shared account holds up to six profiles, each with its own + history, watchlist and preferences. An account with several asks who is + watching, on the web and the TV. The password and passkeys stay on the main + profile; a password change ends the sessions of every profile. +- **Friends**: found by name, never by email, and asked; declining tells the + other person nothing. Home says what friends play now and watched or added + lately. What a profile watches reaches friends only once it shares it, and a + title can be hidden from them. +- **Mascots**: everyone gets a small Iris TV picked from their name, the same on + the web and the TV: who's watching, the header, friends, profiles. + +### Fixed + +- **TV header**: the logo, the tabs and the account's name each pick their ink + from the luminance of what is behind them, the still's fade included, with + the same pass as the channel logos. + ## [1.6.4] - 2026-10-31 ### Added diff --git a/Dockerfile b/Dockerfile index 72de3a76..b0be27c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -123,7 +123,7 @@ COPY --from=planner /app/recipe.json ./recipe.json RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ cargo chef cook --release --bin iris --recipe-path recipe.json -COPY Cargo.toml Cargo.lock* ./ +COPY Cargo.toml Cargo.lock* CHANGELOG.md ./ COPY crates ./crates COPY migrations ./migrations RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt index aa033463..57a3b5f8 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt @@ -3,6 +3,7 @@ package studio.kahn.iris.tv.data import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -36,6 +37,50 @@ interface IrisApi { @POST("api/auth/logout") suspend fun logout() + /** Under `/api/auth`, the only path the refresh cookie goes to: new session cookies on the other profile. */ + @POST("api/auth/switch-profile") + suspend fun switchProfile(@Body body: SwitchProfileRequest): UserResponse + + @GET("api/me/profiles") + suspend fun profiles(): List + + @POST("api/me/profiles") + suspend fun createProfile(@Body body: CreateProfileRequest): List + + @DELETE("api/me/profiles/{id}") + suspend fun removeProfile(@Path("id") id: String) + + @GET("api/me/friends") + suspend fun friends(): FriendsOverview + + @GET("api/me/friends/search") + suspend fun searchFriends(@Query("q") q: String): List + + @GET("api/me/friends/activity") + suspend fun friendActivity(): FriendActivity + + @POST("api/me/friends/{id}") + suspend fun askFriend(@Path("id") id: String) + + @POST("api/me/friends/{id}/accept") + suspend fun acceptFriend(@Path("id") id: String) + + @POST("api/me/friends/{id}/decline") + suspend fun declineFriend(@Path("id") id: String) + + /** Ends a friendship, or takes back one's own request. */ + @DELETE("api/me/friends/{id}") + suspend fun removeFriend(@Path("id") id: String) + + @PUT("api/me/friends/sharing") + suspend fun setSharing(@Body body: SharingRequest) + + @POST("api/me/friends/hidden") + suspend fun hideFromFriends(@Body body: HideRequest) + + @HTTP(method = "DELETE", path = "api/me/friends/hidden", hasBody = true) + suspend fun showToFriends(@Body body: UnhideRequest) + @GET("api/me") suspend fun me(): UserResponse @@ -66,6 +111,10 @@ interface IrisApi { @PUT("api/me/preferences") suspend fun savePreferences(@Body body: UpdatePreferencesRequest): PreferencesResponse + /** The running server's version and its update sentence. */ + @GET("api/release-note") + suspend fun releaseNote(): ReleaseNote + /** Merged movie + TV genre taxonomy for the onboarding picker. Anime * is NOT in here — it's a distinct category driven by * [UpdatePreferencesRequest.includeAnime]. */ diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt index 8a12fcda..d72a8ac8 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt @@ -37,6 +37,7 @@ private val Context.prefsDataStore by preferencesDataStore("iris_prefs") private val KEY_SEARCH_VIEW_MODE = stringPreferencesKey("search_view_mode") private val KEY_LIBRARY_VIEW = stringPreferencesKey("library_view") private val KEY_INTERFACE_SIZE = stringPreferencesKey("interface_size") +private val KEY_RELEASE_SEEN = stringPreferencesKey("release_seen") /** * Small client-side UI preferences — NOT session / auth state (that's @@ -65,6 +66,13 @@ class PrefsStore(private val context: Context) { context.prefsDataStore.edit { it[KEY_LIBRARY_VIEW] = name } } + /** The last version whose update sentence this TV has had (web `STORAGE.releaseSeen`). */ + val releaseSeen: Flow = context.prefsDataStore.data.map { it[KEY_RELEASE_SEEN] } + + suspend fun setReleaseSeen(version: String) { + context.prefsDataStore.edit { it[KEY_RELEASE_SEEN] = version } + } + val interfaceSize: Flow = context.prefsDataStore.data.map { InterfaceSize.named(it[KEY_INTERFACE_SIZE]) } suspend fun setInterfaceSize(size: InterfaceSize) { diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/ProfileChoice.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/ProfileChoice.kt new file mode 100644 index 00000000..b3ba965a --- /dev/null +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/ProfileChoice.kt @@ -0,0 +1,21 @@ +package studio.kahn.iris.tv.data + +/** + * Whether this launch of the app asked who is watching, and how many times the profile changed: + * the home starts over when it did, wherever it was changed from. + */ +object ProfileChoice { + @Volatile var asked = false + + @Volatile var changes = 0 + private set + + fun changed() { + asked = true + changes++ + } + + fun forget() { + asked = false + } +} diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/IrisRoot.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/IrisRoot.kt index ee886851..9a6d9a5d 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/IrisRoot.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/IrisRoot.kt @@ -187,6 +187,7 @@ fun IrisRoot( }, onOpenLibrary = { navController.openTab(TopTab.Library) }, onOpenDiscover = { navController.openTab(TopTab.Discover) }, + onAccountChanged = shell::refresh, ) } section(TopTab.LiveTv, shellHost) { diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/components/Header.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/components/Header.kt index c2e9f4d2..8090ded7 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/components/Header.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/components/Header.kt @@ -1,5 +1,12 @@ package studio.kahn.iris.tv.ui.components +import studio.kahn.iris.tv.ui.nav.HeaderBackdrop +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -59,6 +66,7 @@ enum class TopTab(val label: String) { fun IrisWordmark( modifier: Modifier = Modifier, fontSize: TextUnit = IrisType.brand.fontSize, + ink: Color = IrisColor.ink, ) { val markSize = with(LocalDensity.current) { (fontSize * MARK_TO_TEXT).toDp() } val drop = with(LocalDensity.current) { (fontSize * BOREL_BASELINE_DROP).toDp() } @@ -71,7 +79,7 @@ fun IrisWordmark( Text( "Iris", style = IrisType.brand.copy(fontSize = fontSize, lineHeight = fontSize), - color = IrisColor.ink, + color = ink, modifier = Modifier.offset(y = drop), ) } @@ -93,6 +101,7 @@ fun NavTab( selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + ink: Color? = null, ) { FocusSurface( onClick = onClick, @@ -105,7 +114,7 @@ fun NavTab( shape = IrisShape.pill, colors = FocusColors.Quiet.copy( container = if (selected) IrisColor.accentWash else Color.Transparent, - content = if (selected) IrisColor.accent else IrisColor.inkMuted, + content = if (selected) IrisColor.accent else ink ?: IrisColor.inkMuted, ), ) { Box(Modifier.padding(horizontal = 12.dp), contentAlignment = Alignment.Center) { @@ -133,13 +142,15 @@ fun TvHeader( modifier: Modifier = Modifier, onAccount: (() -> Unit)? = null, updateAvailable: Boolean = false, + /** What is drawn behind the header: each thing in it reads the part behind it. */ + backdrop: HeaderBackdrop? = null, ) { val currentTab = remember { FocusRequester() } Row( modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - IrisWordmark() + OverBackdrop(backdrop) { ink, probe -> IrisWordmark(probe, ink = ink ?: IrisColor.ink) } Spacer(Modifier.width(IrisSpace.s6)) Row( Modifier @@ -148,65 +159,72 @@ fun TvHeader( horizontalArrangement = Arrangement.spacedBy(IrisSpace.s1), ) { TopTab.entries.forEach { tab -> - NavTab( - text = tab.label, - selected = tab == current, - onClick = { onSelect(tab) }, - modifier = if (tab == current) Modifier.focusRequester(currentTab) else Modifier, - ) + OverBackdrop(backdrop) { ink, probe -> + NavTab( + text = tab.label, + selected = tab == current, + onClick = { onSelect(tab) }, + modifier = probe.then(if (tab == current) Modifier.focusRequester(currentTab) else Modifier), + ink = ink, + ) + } } } Spacer(Modifier.weight(1f)) if (accountName != null) { - if (onAccount != null) { - FocusSurface( - onClick = onAccount, - modifier = Modifier.heightIn(min = IrisSize.controlSmall), - shape = IrisShape.pill, - colors = FocusColors.Quiet.copy(content = IrisColor.inkMuted), - ) { focused -> - Account( - accountName, - focused, - updateAvailable, - Modifier - .padding(start = 3.dp, end = 12.dp) - .clearAndSetSemantics { - contentDescription = if (updateAvailable) { - "Settings, update available, $accountName" - } else { - "Settings, $accountName" - } - }, - ) + OverBackdrop(backdrop) { ink, probe -> + if (onAccount != null) { + FocusSurface( + onClick = onAccount, + modifier = probe.heightIn(min = IrisSize.controlSmall), + shape = IrisShape.pill, + colors = FocusColors.Quiet.copy(content = IrisColor.inkMuted), + ) { focused -> + Account( + accountName, + focused, + updateAvailable, + ink, + Modifier + .padding(start = 3.dp, end = 12.dp) + .clearAndSetSemantics { + contentDescription = if (updateAvailable) { + "Settings, update available, $accountName" + } else { + "Settings, $accountName" + } + }, + ) + } + } else { + Account(accountName, focused = false, update = false, ink = ink, modifier = probe) } - } else { - Account(accountName, focused = false, update = false) } } } } +/** [content] in the words that read over the part of [backdrop] behind it, told by the probe it wears. */ +@Composable +private fun OverBackdrop(backdrop: HeaderBackdrop?, content: @Composable (ink: Color?, probe: Modifier) -> Unit) { + var span by remember { mutableStateOf(0f to 0f) } + val probe = Modifier.onGloballyPositioned { at -> + val width = at.findRootCoordinates().size.width.toFloat() + val bounds = at.boundsInRoot() + if (width > 0f) span = bounds.left / width to bounds.right / width + } + content(backdrop?.inkAt(span.first, span.second), probe) +} + @Composable -private fun Account(name: String, focused: Boolean, update: Boolean, modifier: Modifier = Modifier) { +private fun Account(name: String, focused: Boolean, update: Boolean, ink: Color? = null, modifier: Modifier = Modifier) { Row( modifier, horizontalArrangement = Arrangement.spacedBy(IrisSpace.s2), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(IrisSize.avatar)) { - Box( - Modifier - .matchParentSize() - .background(IrisColor.accentWash, IrisShape.circle), - contentAlignment = Alignment.Center, - ) { - Text( - name.trim().take(1).uppercase(), - style = IrisType.chip.copy(fontWeight = FontWeight.Bold), - color = IrisColor.accent, - ) - } + Mascot(name, IrisSize.avatar) if (update) { Box( Modifier @@ -222,7 +240,7 @@ private fun Account(name: String, focused: Boolean, update: Boolean, modifier: M Text( name, style = IrisType.meta, - color = if (focused) IrisColor.ground else IrisColor.inkMuted, + color = if (focused) IrisColor.ground else ink ?: IrisColor.inkMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -230,7 +248,7 @@ private fun Account(name: String, focused: Boolean, update: Boolean, modifier: M Text( "Update", style = IrisType.meta.copy(fontWeight = FontWeight.Bold), - color = if (focused) IrisColor.ground else IrisColor.accent, + color = if (focused) IrisColor.ground else ink ?: IrisColor.accent, maxLines = 1, ) } diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/components/Mascot.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/components/Mascot.kt new file mode 100644 index 00000000..f9b0b2ae --- /dev/null +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/components/Mascot.kt @@ -0,0 +1,89 @@ +package studio.kahn.iris.tv.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.Dp +import studio.kahn.iris.tv.ui.theme.IrisColor + +private val ANTENNAS = listOf("M19 8.5l5 6 5-6", "M24 14.5V9.5M24 6.5v.01", "M18 14.5l-2-6M30 14.5l2-6", "M24 14.5l5-6 4 2") + +private val EYES = listOf( + "M16.4 25.4a2.3 2.3 0 0 1 4.4 0M27.2 25.4a2.3 2.3 0 0 1 4.4 0", + "M16.4 24a2.3 2.3 0 0 0 4.4 0M27.2 24a2.3 2.3 0 0 0 4.4 0", + "M18.6 23.2v2.8M29.4 23.2v2.8", + "M16.4 25.4a2.3 2.3 0 0 1 4.4 0M29.4 23.2v2.8", + "M16.2 24.8h4.8M27 24.8h4.8", + "M16.6 23.4l4 1.6M31.4 23.4l-4 1.6", +) + +private val SCREENS = listOf( + "M15.5 15h17a6 6 0 0 1 6 6v10a6 6 0 0 1-6 6h-17a6 6 0 0 1-6-6v-10a6 6 0 0 1 6-6z", + "M12.5 15h23a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3h-23a3 3 0 0 1-3-3v-16a3 3 0 0 1 3-3z", + "M19.5 15h9a10 10 0 0 1 10 10v2a10 10 0 0 1-10 10h-9a10 10 0 0 1-10-10v-2a10 10 0 0 1 10-10z", +) + +private val FEET = listOf("M19 41h10", "M15 38v3M33 38v3", "M14 41h20") + +private const val BLUSH = "M13.2 29.6a2 1.2 0 1 0 4 0a2 1.2 0 1 0-4 0zM30.8 29.6a2 1.2 0 1 0 4 0a2 1.2 0 1 0-4 0z" + +private const val GRID = 48f +private const val TILE_CORNER = 11f +private const val LINE = 2.4f +private const val EYE_LINE = 1.7f + +/** Which of each part a name draws: indexes into the tables above, the same as the web's. */ +@Immutable +data class MascotParts(val tile: Int, val antenna: Int, val eyes: Int, val screen: Int, val foot: Int) + +/** FNV-1a over the UTF-8 of the name, trimmed and in lower case (web `mascotHash`). */ +fun mascotHash(name: String): Long { + var h = 0x811c9dc5L + for (byte in name.trim().lowercase().toByteArray()) { + h = ((h xor (byte.toLong() and 0xFF)) * 0x01000193L) and 0xFFFFFFFFL + } + return h +} + +/** A person's mascot (web `@iris/api/mascot`): `MascotTest` and the web's test hold the same vectors. */ +fun mascotOf(name: String): MascotParts { + var h = mascotHash(name) + fun take(n: Int): Int = (h % n).toInt().also { h /= n } + return MascotParts(take(IrisColor.mascots.size), take(ANTENNAS.size), take(EYES.size), take(SCREENS.size), take(FEET.size)) +} + +private fun path(d: String): Path = PathParser().parsePathString(d).toPath() + +/** A person's mascot, the Iris mark's grammar: decoration beside their name, which always says it. */ +@Composable +fun Mascot(name: String, size: Dp, modifier: Modifier = Modifier) { + val parts = remember(name) { mascotOf(name) } + val shapes = remember(parts) { + listOf(ANTENNAS[parts.antenna], FEET[parts.foot], SCREENS[parts.screen], BLUSH, EYES[parts.eyes]).map(::path) + } + val (antenna, foot, screen, blush, eyes) = shapes + val tile = IrisColor.mascots[parts.tile] + Canvas(modifier.size(size)) { + scale(this.size.width / GRID, pivot = Offset.Zero) { + val line = Stroke(LINE, cap = StrokeCap.Round, join = StrokeJoin.Round) + drawRoundRect(tile, size = Size(GRID, GRID), cornerRadius = CornerRadius(TILE_CORNER)) + drawPath(antenna, IrisColor.artInk, style = line) + drawPath(screen, IrisColor.artInk) + drawPath(eyes, tile, style = Stroke(EYE_LINE, cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawPath(blush, IrisColor.mascotBlush) + drawPath(foot, IrisColor.artInk, style = line) + } + } +} diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/format/Luminance.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/format/Luminance.kt new file mode 100644 index 00000000..04e5176b --- /dev/null +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/format/Luminance.kt @@ -0,0 +1,38 @@ +package studio.kahn.iris.tv.ui.format + +import android.graphics.Bitmap +import androidx.core.graphics.get + +/** + * Mean relative luminance of the opaque pixels of a [bitmap], sampled on a [grid]×[grid] grid, within + * the part given as fractions of its size; null when nothing there is opaque. + */ +fun meanLuminance(bitmap: Bitmap, fromX: Float = 0f, toX: Float = 1f, toY: Float = 1f, grid: Int = 32): Double? { + val startX = (bitmap.width * fromX).toInt() + val endX = (bitmap.width * toX).toInt().coerceAtMost(bitmap.width) + val endY = (bitmap.height * toY).toInt() + val stepX = maxOf(1, (endX - startX) / grid) + val stepY = maxOf(1, endY / grid) + var luma = 0.0 + var count = 0 + var y = 0 + while (y < endY) { + var x = startX + while (x < endX) { + val px = bitmap[x, y] + if (px ushr 24 and 0xFF > 25) { + luma += 0.2126 * linear(px ushr 16 and 0xFF) + 0.7152 * linear(px ushr 8 and 0xFF) + + 0.0722 * linear(px and 0xFF) + count++ + } + x += stepX + } + y += stepY + } + return if (count == 0) null else luma / count +} + +private fun linear(c: Int): Double { + val v = c / 255.0 + return if (v <= 0.04045) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4) +} diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/nav/HeaderBackdrop.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/nav/HeaderBackdrop.kt new file mode 100644 index 00000000..52c31ee5 --- /dev/null +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/nav/HeaderBackdrop.kt @@ -0,0 +1,26 @@ +package studio.kahn.iris.tv.ui.nav + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import studio.kahn.iris.tv.ui.screens.live.toneOf + +/** + * What the header sits on, across the screen: a relative luminance per cell, null where only the + * ground shows. Each thing in the header reads the part behind it, so the logo on the ground keeps + * its colours while the name beside it goes dark over a bright still. + */ +@Immutable +class HeaderBackdrop(private val cells: List) { + /** The words that read best between [from] and [to], fractions of the screen; null: the usual ones. */ + fun inkAt(from: Float, to: Float): Color? { + if (cells.isEmpty()) return null + val first = (from * cells.size).toInt().coerceIn(0, cells.lastIndex) + val last = (to * cells.size).toInt().coerceIn(first, cells.lastIndex) + val seen = cells.subList(first, last + 1).filterNotNull() + val mean = if (seen.isEmpty()) return null else seen.average() + return if (mean < NEAR_GROUND) null else toneOf(mean).plate + } +} + +/** Below this a still reads as the ground: the usual words hold, and two tabs side by side stay alike. */ +private const val NEAR_GROUND = 0.04 diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/nav/Shell.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/nav/Shell.kt index e669b50e..fc5fe0e5 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/nav/Shell.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/nav/Shell.kt @@ -112,6 +112,8 @@ private enum class ShellSlot { Backdrop, Header, Content } @Stable class ShellBackdropHolder { var content by mutableStateOf<(@Composable () -> Unit)?>(null) + /** What the header sits on; null when the backdrop draws nothing behind it. */ + var header by mutableStateOf(null) } val LocalShellBackdrop = staticCompositionLocalOf { null } @@ -176,6 +178,7 @@ fun TopLevelShell( val header = remember { FocusRequester() } var headerFocused by remember { mutableStateOf(false) } val back = shellBack(tab, headerFocused) + val backdrop = remember { ShellBackdropHolder() } BackHandler(enabled = back != ShellBack.Leave) { when (back) { ShellBack.ToHeader -> header.requestFocus() @@ -202,10 +205,10 @@ fun TopLevelShell( accountName = accountName, onAccount = onAccount, updateAvailable = updateAvailable, + backdrop = backdrop.header, ) } } - val backdrop = remember { ShellBackdropHolder() } CompositionLocalProvider(LocalShellHeader provides header, LocalShellBackdrop provides backdrop.takeIf { headerOverContent }) { if (headerOverContent) { // Drawn bottom to top: the content's backdrop, the header, the content (its dialogs diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt index 21ed6ba2..aa5b40ff 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt @@ -1,5 +1,8 @@ package studio.kahn.iris.tv.ui.screens +import studio.kahn.iris.tv.ui.components.ActionStyle +import studio.kahn.iris.tv.ui.components.ActionSize +import studio.kahn.iris.tv.ui.components.ActionButton import studio.kahn.iris.tv.ui.state.SearchInstead import studio.kahn.iris.tv.ui.state.SearchInsteadEffect import androidx.activity.compose.BackHandler @@ -75,6 +78,7 @@ import studio.kahn.iris.tv.ui.components.NoticeLine data class HistoryActions( val onPlay: (infohash: String, fileIdx: Int) -> Unit = { _, _ -> }, val onRestore: (HistoryLine) -> Unit = {}, + val onHide: (HistoryGroupUi) -> Unit = {}, val onOpenTitle: (collectionId: String) -> Unit = {}, val onRetry: () -> Unit = {}, val onBack: () -> Unit = {}, @@ -108,6 +112,7 @@ fun HistoryScreen( actions = HistoryActions( onPlay = onPickFile, onRestore = vm::restore, + onHide = vm::hide, onOpenTitle = onOpenCollection, onRetry = vm::retry, onBack = onBack, @@ -256,6 +261,13 @@ private fun HistoryGroupRow( LineCard(group, line, busy, actions, Modifier.padding(start = IrisSpace.s6).focusReturn(keys, "line:${line.key}")) } } + ActionButton( + "Hide from friends", + { actions.onHide(group) }, + style = ActionStyle.Secondary, + size = ActionSize.Small, + busy = "hide:${group.key}" in busy, + ) } } } diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt index be54a179..efbb25df 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt @@ -1,5 +1,21 @@ package studio.kahn.iris.tv.ui.screens +import studio.kahn.iris.tv.ui.screens.home.heroBackdrop +import studio.kahn.iris.tv.ui.screens.home.HERO_ART_WIDTH +import studio.kahn.iris.tv.ui.screens.home.HERO_ART_HEIGHT +import studio.kahn.iris.tv.ui.screens.home.FADE_LEFT +import studio.kahn.iris.tv.ui.screens.home.FADE_BOTTOM +import studio.kahn.iris.tv.ui.nav.HeaderBackdrop +import studio.kahn.iris.tv.ui.components.Mascot +import studio.kahn.iris.tv.ui.screens.home.WhoIsWatching +import studio.kahn.iris.tv.ui.screens.home.FriendLine +import studio.kahn.iris.tv.ui.nav.LocalShellBackdrop +import studio.kahn.iris.tv.ui.components.RowCard +import java.util.concurrent.ConcurrentHashMap +import coil3.toBitmap +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.DisposableEffect import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.relocation.BringIntoViewRequester @@ -55,6 +71,7 @@ import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil3.compose.AsyncImage import coil3.request.ImageRequest +import coil3.request.allowHardware import studio.kahn.iris.tv.data.AppContainer import studio.kahn.iris.tv.data.AppUpdater import studio.kahn.iris.tv.data.UpdateNotice @@ -115,6 +132,7 @@ fun HomeScreen( onOpenSearch: (query: String?) -> Unit, onOpenLibrary: () -> Unit, onOpenDiscover: () -> Unit, + onAccountChanged: () -> Unit = {}, ) { val vm = irisViewModel(container) { c, _ -> HomeViewModel(c) } val state by vm.state.collectAsStateWithLifecycle() @@ -126,6 +144,7 @@ fun HomeScreen( is HomeEvent.OpenCollection -> onOpenCollection(event.id) is HomeEvent.Search -> onOpenSearch(event.query) HomeEvent.OpenLibrary -> onOpenLibrary() + HomeEvent.ProfileChanged -> onAccountChanged() } } } @@ -136,6 +155,8 @@ fun HomeScreen( state = state, update = updateNotice(updates), updateActions = updateActions, + onCloseReleaseNote = vm::closeReleaseNote, + onOpenFriendTitle = onOpenCollection, onHeroAction = vm::onHeroAction, onCardAction = vm::onCardAction, onRetry = vm::retry, @@ -146,6 +167,7 @@ fun HomeScreen( state.onboarding?.let { prefs -> OnboardingSheet(container, prefs, onClosed = vm::onboardingClosed) } + state.whoIsWatching?.let { WhoIsWatching(it, state.notice, vm::enterProfile) } } } @@ -160,6 +182,8 @@ fun HomeContent( onOpenSearch: () -> Unit, update: UpdateNotice? = null, updateActions: UpdateActions = UpdateActions(), + onCloseReleaseNote: () -> Unit = {}, + onOpenFriendTitle: (String) -> Unit = {}, ) { val layout = IrisLayout.current val header = LocalShellHeader.current @@ -170,11 +194,11 @@ fun HomeContent( // A card focused during this visit before the hero came (the platform's default focus lands // on the first focusable) is not a card left: only a return goes back to one. val returning = remember { focus.returns.last != null } - val heroIndex by rememberUpdatedState(if (update != null) 1 else 0) + val heroIndex by rememberUpdatedState(listOfNotNull(update, state.releaseNote).size) // The first focus: back on this screen the card left, else the hero's main action, else the // menu once the home knows it has no hero. The onboarding sheet closing puts it back too. - val sheetOpen = state.onboarding != null + val sheetOpen = state.onboarding != null || state.whoIsWatching != null var sheetWasOpen by remember { mutableStateOf(false) } LaunchedEffect(sheetOpen) { if (sheetOpen) { @@ -236,6 +260,16 @@ fun HomeContent( UpdateBanner(update, updateActions, Modifier.padding(horizontal = layout.safeHorizontal)) } } + state.releaseNote?.let { note -> + item(key = "release-note", contentType = "update") { + NoticeLine( + note, + Modifier.padding(horizontal = layout.safeHorizontal), + onClose = onCloseReleaseNote, + takeFocus = false, + ) + } + } if (hero != null) { item(key = HERO_KEY, contentType = "hero") { Hero(hero, state.busy, heroFocus, onHeroAction, Modifier.padding(horizontal = layout.safeHorizontal)) @@ -253,6 +287,11 @@ fun HomeContent( ) } item(key = "now", contentType = "now") { RightNow(state.rightNow) } + if (state.friends.isNotEmpty()) { + item(key = "friends", contentType = "friends") { + FriendsActivity(state.friends, onOpenFriendTitle, Modifier.padding(horizontal = layout.safeHorizontal)) + } + } item(key = "watchlist", contentType = "row") { HomeRow( title = "Your watchlist", @@ -319,8 +358,6 @@ fun HomeContent( private const val HERO_KEY = "hero" // The board's hero still: 1100 x 620 of 1920 x 1080, in the top right corner. -private const val HERO_ART_WIDTH = 1100f / 1920f -private const val HERO_ART_HEIGHT = 620f / 1080f /** * The hero's still in the screen's top right corner, under the header, fading into the @@ -330,6 +367,11 @@ private const val HERO_ART_HEIGHT = 620f / 1080f */ @Composable private fun HeroArt(url: String?, width: Dp, height: Dp, list: LazyListState, modifier: Modifier = Modifier) { + val holder = LocalShellBackdrop.current + var header by remember(url) { mutableStateOf(url?.let(heroHeaderCache::get)) } + val shown by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.any { it.key == HERO_KEY } } } + LaunchedEffect(holder, header, shown) { holder?.header = header.takeIf { shown } } + DisposableEffect(holder) { onDispose { holder?.header = null } } Box( modifier .size(width, height) @@ -346,7 +388,12 @@ private fun HeroArt(url: String?, width: Dp, height: Dp, list: LazyListState, mo val density = LocalDensity.current val request = remember(url, width, height, density) { with(density) { - ImageRequest.Builder(context).data(url).size(width.roundToPx(), height.roundToPx()).build() + // Software only while the luminance pass still has to read the pixels (2 GB boxes). + ImageRequest.Builder(context) + .data(url) + .size(width.roundToPx(), height.roundToPx()) + .allowHardware(heroHeaderCache.containsKey(url)) + .build() } } AsyncImage( @@ -354,6 +401,11 @@ private fun HeroArt(url: String?, width: Dp, height: Dp, list: LazyListState, mo contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), + onSuccess = { state -> + header = heroHeaderCache.getOrPut(url) { + runCatching { heroBackdrop(state.result.image.toBitmap()) }.getOrDefault(HeaderBackdrop(emptyList())) + } + }, ) } Box( @@ -369,8 +421,9 @@ private fun HeroArt(url: String?, width: Dp, height: Dp, list: LazyListState, mo } } -private const val FADE_LEFT = 0.45f -private const val FADE_BOTTOM = 0.6f +/** One luminance pass per still: the home recomposes all the time. */ +private val heroHeaderCache = ConcurrentHashMap() + @Composable private fun Hero( @@ -437,6 +490,21 @@ private fun Hero( private val COMPACT_HEIGHT = 480.dp +/** What friends do, who plays now first; a line with a title opens it (web `FriendsActivity`). */ +@Composable +private fun FriendsActivity(lines: List, onOpen: (String) -> Unit, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(IrisSpace.s2)) { + Eyebrow("Friends") + lines.forEach { line -> + RowCard(onClick = { line.collectionId?.let(onOpen) }, enabled = line.collectionId != null) { _ -> + Mascot(line.friend, IrisSize.avatar) + Text(line.text, style = IrisType.body, color = IrisColor.ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + line.whenSaid?.let { Text(it, style = IrisType.meta, color = IrisColor.inkMuted) } + } + } + } +} + /** "Right now": what is downloading, in a few words; nothing when nothing is (web `RightNow`). */ @Composable private fun RightNow(facts: Loadable>) { diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt index b6d9a0b9..47203de8 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt @@ -132,6 +132,17 @@ fun SettingsScreen( onPair = vm::pair, onStopWaiting = vm::stopWaiting, onRevoke = vm::revoke, + onAddProfile = vm::addProfile, + onSwitchProfile = vm::switchProfile, + onRemoveProfile = vm::removeProfile, + onFindFriends = vm::findFriends, + onAskFriend = vm::askFriend, + onAcceptFriend = vm::acceptFriend, + onDeclineFriend = vm::declineFriend, + onRemoveFriend = vm::removeFriend, + onTakeBack = vm::takeBack, + onShare = vm::setSharing, + onShowToFriends = vm::showToFriends, onChangePassword = vm::changePassword, onSignOut = vm::signOut, onInterfaceSize = vm::setInterfaceSize, @@ -198,7 +209,7 @@ fun SettingsContent( .padding(IrisSpace.s1), verticalArrangement = Arrangement.spacedBy(IrisSpace.s1), ) { - SettingsSection.entries.forEach { s -> + state.sections.forEach { s -> RailItem( if (s == SettingsSection.App && update.available != null) "App update · new" else s.label, selected = s == section, @@ -250,7 +261,7 @@ private fun PageHead(state: SettingsUiState, actions: SettingsActions) { Text("Settings", style = IrisType.page, color = IrisColor.ink) val user = state.account.valueOrNull if (user != null) { - Text("${user.displayName} · ${user.email}", style = IrisType.meta, color = IrisColor.inkMuted, maxLines = 1) + Text(if (user.accountId == null) "${user.displayName} · ${user.email}" else user.displayName, style = IrisType.meta, color = IrisColor.inkMuted, maxLines = 1) } } ActionButton("Watch history", actions.onOpenHistory, style = ActionStyle.Secondary, size = ActionSize.Small) @@ -395,6 +406,24 @@ private fun SettingsDialogs(state: SettingsUiState, actions: SettingsActions) { onPick = actions.onSaveSubtitles, onDismiss = actions.onCloseDialog, ) + SettingsDialog.AddProfile -> NameDialog("Profiles", "A new profile", "Name", "Add the profile", state, Busy.PROFILE, actions.onAddProfile, actions.onCloseDialog) + SettingsDialog.FindFriend -> NameDialog("Friends", "Find someone", "Their name", "Find", state, Busy.FRIENDS, actions.onFindFriends, actions.onCloseDialog) + is SettingsDialog.RemoveProfile -> ConfirmDialog( + eyebrow = "Profiles", + title = "Remove ${dialog.profile.name}?", + body = "Its history, watchlist and preferences go. What it added stays in the library.", + confirmLabel = "Remove the profile", + onConfirm = { actions.onRemoveProfile(dialog.profile) }, + onCancel = actions.onCloseDialog, + ) + is SettingsDialog.RemoveFriend -> ConfirmDialog( + eyebrow = "Friends", + title = "Remove ${dialog.person.name}?", + body = "You stop seeing each other’s activity. They are not told.", + confirmLabel = "Remove the friend", + onConfirm = { actions.onRemoveFriend(dialog.person) }, + onCancel = actions.onCloseDialog, + ) is SettingsDialog.Revoke -> ConfirmDialog( eyebrow = "Devices", title = "Sign out ${dialog.device.name()}?", @@ -417,6 +446,44 @@ private fun SettingsDialogs(state: SettingsUiState, actions: SettingsActions) { } } +/** One name to enter: a new profile, someone to find. */ +@Composable +private fun NameDialog( + eyebrow: String, + title: String, + label: String, + confirm: String, + state: SettingsUiState, + busy: String, + onConfirm: (String) -> Unit, + onCancel: () -> Unit, +) { + var name by remember { mutableStateOf("") } + val first = remember { FocusRequester() } + FormDialog( + eyebrow = eyebrow, + title = title, + confirmLabel = confirm, + onConfirm = { onConfirm(name) }, + onCancel = onCancel, + firstFocus = first, + busy = state.busy == busy, + busyText = "Saving…", + ) { + TextInput( + name, + { name = it }, + label = label, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onConfirm(name) }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(first), + ) + state.dialogError?.let { StatusLine(it.text, tone = StatusTone.Down) } + } +} + private const val LABEL_MAX = 64 /** The language choices of a [SidePanel], the current one ticked and focused; picking saves it. */ diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HeroStill.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HeroStill.kt new file mode 100644 index 00000000..9eee305b --- /dev/null +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HeroStill.kt @@ -0,0 +1,46 @@ +package studio.kahn.iris.tv.ui.screens.home + +import android.graphics.Bitmap +import kotlin.math.pow +import studio.kahn.iris.tv.ui.format.meanLuminance +import studio.kahn.iris.tv.ui.nav.HeaderBackdrop + +/** The hero's still: a part of the screen, right-aligned, faded into the ground from its left. */ +const val HERO_ART_WIDTH = 1100f / 1920f +const val HERO_ART_HEIGHT = 620f / 1080f +const val FADE_LEFT = 0.45f +const val FADE_BOTTOM = 0.6f + +private const val HEADER_CELLS = 48 +private const val CELL_GRID = 6 +/** The height of the still the header covers. */ +private const val HEADER_TO_Y = 0.2f +private const val GROUND_LUMINANCE = 0.01 +private const val GAMMA = 2.2 + +/** The header's backdrop from [art], the luminance of a part of the still given as fractions of its width. */ +internal fun heroBackdrop(art: (from: Float, to: Float) -> Double?): HeaderBackdrop { + val left = 1f - HERO_ART_WIDTH + return HeaderBackdrop( + List(HEADER_CELLS) { i -> + val start = (i.toFloat() / HEADER_CELLS - left) / HERO_ART_WIDTH + val end = ((i + 1f) / HEADER_CELLS - left) / HERO_ART_WIDTH + val middle = (start + end) / 2 + val ground = (1f - middle / FADE_LEFT).coerceIn(0f, 1f) + if (end <= 0f || ground >= 1f) { + null + } else { + art(start.coerceAtLeast(0f), end)?.let { faded(it, ground) } + } + }, + ) +} + +/** [luminance] seen through a fade [ground] opaque: the screen blends gamma-encoded values, not light. */ +private fun faded(luminance: Double, ground: Float): Double { + val encoded = luminance.pow(1 / GAMMA) * (1f - ground) + GROUND_LUMINANCE.pow(1 / GAMMA) * ground + return encoded.pow(GAMMA) +} + +internal fun heroBackdrop(still: Bitmap): HeaderBackdrop = + heroBackdrop { from, to -> meanLuminance(still, fromX = from, toX = to, toY = HEADER_TO_Y, grid = CELL_GRID) } diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt index 5d9595ce..07cf049a 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt @@ -34,13 +34,19 @@ import studio.kahn.iris.tv.data.ContinueWatchingItem import studio.kahn.iris.tv.data.DismissCwRequest import studio.kahn.iris.tv.data.DismissRequest import studio.kahn.iris.tv.data.FeaturedResponse +import studio.kahn.iris.tv.data.FriendAction +import studio.kahn.iris.tv.data.FriendActivity import studio.kahn.iris.tv.data.ForYou import studio.kahn.iris.tv.data.HomeSummary import studio.kahn.iris.tv.data.MediaKind import studio.kahn.iris.tv.data.MediaMetadata import studio.kahn.iris.tv.data.PreferencesResponse +import studio.kahn.iris.tv.data.ProfileChoice +import studio.kahn.iris.tv.data.ProfileView import studio.kahn.iris.tv.data.ProgressUpdate +import studio.kahn.iris.tv.data.ReleaseNote import studio.kahn.iris.tv.data.RemoveWatchlistRequest +import studio.kahn.iris.tv.data.SwitchProfileRequest import studio.kahn.iris.tv.data.TorrentView import studio.kahn.iris.tv.data.WatchlistItem import studio.kahn.iris.tv.ui.components.StatusTone @@ -49,6 +55,8 @@ import studio.kahn.iris.tv.ui.state.load import studio.kahn.iris.tv.ui.state.map import studio.kahn.iris.tv.ui.state.toUiError import studio.kahn.iris.tv.ui.components.Notice +import studio.kahn.iris.tv.ui.format.AgoStyle +import studio.kahn.iris.tv.ui.format.ago import studio.kahn.iris.tv.ui.state.FAST_MS import studio.kahn.iris.tv.ui.state.SLOW_MS import studio.kahn.iris.tv.ui.state.pollWhile @@ -58,6 +66,7 @@ sealed interface HomeEvent { data class Play(val infohash: String, val fileIdx: Int) : HomeEvent data class OpenCollection(val id: String) : HomeEvent data class Search(val query: String) : HomeEvent + data object ProfileChanged : HomeEvent data object OpenLibrary : HomeEvent } @@ -77,6 +86,11 @@ data class HomeUiState( /** The action in flight (`get:`, `remove:`…): one at a time. */ val busy: String? = null, val notice: Notice? = null, + /** What changed, once after an update. */ + val releaseNote: Notice? = null, + val friends: List = emptyList(), + /** Non-null while the account's profiles wait for who is watching. */ + val whoIsWatching: List? = null, /** Non-null while the first-run preferences sheet is due. */ val onboarding: PreferencesResponse? = null, ) @@ -100,6 +114,9 @@ internal data class HomeData( val onboardingClosed: Boolean = false, val busy: String? = null, val notice: Notice? = null, + val release: ReleaseNote? = null, + val friends: FriendActivity? = null, + val profiles: List? = null, ) private const val LIBRARY_ROW = 12 @@ -108,6 +125,26 @@ internal fun ContinueWatchingItem.metaKey(): MetaKey? = tmdbId?.takeIf { tmdbVer internal fun CollectionListItem.metaKey(): MetaKey? = tmdbId?.let { MetaKey(it, kind) } private fun FeaturedResponse.pick() = movies.firstOrNull() ?: series.firstOrNull() +/** The update sentence still to say to a TV that last saw [seen] (web `noteDue`): none on a first launch, nor once this version was seen. */ +internal fun noteDue(release: ReleaseNote, seen: String?): String? = + release.note.takeIf { seen != null && seen != release.version } + +/** One line of what friends do; [collectionId] null when it leads nowhere. */ +@Immutable +data class FriendLine(val key: String, val friend: String, val text: String, val whenSaid: String?, val collectionId: String?) + +private const val FRIEND_LINES = 6 + +internal fun friendLines(a: FriendActivity): List = (a.live + a.recent).take(FRIEND_LINES).map { e -> + FriendLine( + key = "${e.action}:${e.friendId}:${e.collectionId ?: e.title}", + friend = e.friendName, + text = friendLine(e), + whenSaid = ago(e.at, AgoStyle.Short).takeIf { e.action != FriendAction.watching }, + collectionId = e.collectionId?.toString(), + ) +} + /** The home's words from what it read. Pure: the unit tests feed it. */ internal fun homeUi(d: HomeData): HomeUiState { val downloads = downloadsByCollection(d.torrents) @@ -142,7 +179,10 @@ internal fun homeUi(d: HomeData): HomeUiState { libraryCount = collections.size, busy = d.busy, notice = d.notice, - onboarding = d.preferences?.takeIf { !it.onboardingCompleted && !d.onboardingClosed }, + releaseNote = d.release?.note?.let { Notice("Updated to ${d.release.version} · $it") }, + friends = d.friends?.let(::friendLines).orEmpty(), + whoIsWatching = d.profiles, + onboarding = d.preferences?.takeIf { !it.onboardingCompleted && !d.onboardingClosed && d.profiles == null }, ) } @@ -172,6 +212,8 @@ class HomeViewModel( private var forYouReadAt: Long? = null private var collectionsReadAt: Long? = null private val actions = BusyActions(viewModelScope, oneAtATime = true) + private var releaseRead = false + private var profileChanges = ProfileChoice.changes init { viewModelScope.launch { @@ -183,6 +225,8 @@ class HomeViewModel( suspend fun refreshWhileStarted() { // The last action's words belong to the visit they were said in (web notices pass). if (data.value.busy == null) actions.say(null) + if (profileChanges != ProfileChoice.changes) startOver() + readProfiles() refreshRows() pollWhile({ if (somethingMoves()) FAST_MS else SLOW_MS }) { refreshLive() } } @@ -201,15 +245,63 @@ class HomeViewModel( launch { readWatchlist() } launch { readCollections() } launch { readPreferences() } + launch { readReleaseNote() } if (force || olderThan(forYouReadAt, FOR_YOU_STALE_MS)) launch { readForYou() } } + /** Another profile: nothing read for the last one stays. */ + private fun startOver() { + profileChanges = ProfileChoice.changes + forYouReadAt = null + collectionsReadAt = null + metaInFlight.clear() + data.value = HomeData() + } + + private suspend fun readProfiles() { + if (ProfileChoice.asked) return + val profiles = bestEffort { container.api().profiles() } ?: return + if (profiles.size > 1) data.update { it.copy(profiles = profiles) } else ProfileChoice.asked = true + } + + fun enterProfile(profile: ProfileView) { + act("profile") { + if (profile.current) { + ProfileChoice.asked = true + data.update { it.copy(profiles = null) } + } else { + container.api().switchProfile(SwitchProfileRequest(profileId = profile.id)) + ProfileChoice.changed() + startOver() + eventChannel.send(HomeEvent.ProfileChanged) + refreshRows(force = true) + } + null + } + } + + private suspend fun readReleaseNote() { + if (releaseRead) return + releaseRead = true + val release = bestEffort { container.api().releaseNote() } ?: return + val due = noteDue(release, container.prefsStore.releaseSeen.first()) != null + if (due) data.update { it.copy(release = release) } else container.prefsStore.setReleaseSeen(release.version) + } + + fun closeReleaseNote() { + val release = data.value.release ?: return + data.update { it.copy(release = null) } + viewModelScope.launch { container.prefsStore.setReleaseSeen(release.version) } + } + private suspend fun refreshLive() = coroutineScope { val summary = async { load(data.value.summary) { absentAs(null) { container.api().homeSummary() } } } val torrents = async { bestEffort { container.api().libraryTorrents().items } } + val friends = async { bestEffort { container.api().friendActivity() } } val s = summary.await() val t = torrents.await() - data.update { it.copy(summary = s, torrents = t ?: it.torrents) } + val f = friends.await() + data.update { it.copy(summary = s, torrents = t ?: it.torrents, friends = f ?: it.friends) } val every = if (somethingMoves()) COLLECTIONS_MOVING_MS else COLLECTIONS_IDLE_MS if (olderThan(collectionsReadAt, every)) readCollections() } diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeWords.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeWords.kt index c8273c92..9a9f832d 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeWords.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeWords.kt @@ -2,6 +2,8 @@ package studio.kahn.iris.tv.ui.screens.home import studio.kahn.iris.tv.ui.format.isResumable import studio.kahn.iris.tv.data.ContinueWatchingItem +import studio.kahn.iris.tv.data.FriendAction +import studio.kahn.iris.tv.data.FriendEvent import studio.kahn.iris.tv.data.HomeSummary import studio.kahn.iris.tv.data.TorrentView import studio.kahn.iris.tv.ui.format.episodeCode @@ -23,6 +25,18 @@ fun rightNow(s: HomeSummary): List = buildList { val eta = s.downloadingEtaSeconds?.takeIf { it > 0 }?.let { " · ${timeLeft(it.toDouble())}" }.orEmpty() add("${plural(s.downloading, "download")} · ${percent(s.downloadingPct)}$eta") } + s.friendRequests?.takeIf { it > 0 }?.let { add(plural(it, "friend request")) } +} + +/** What a friend does, in a sentence: "Muriel is watching Severance · S2:E4". */ +fun friendLine(e: FriendEvent): String { + val does = when (e.action) { + FriendAction.watching -> "is watching" + FriendAction.added -> "added" + else -> "watched" + } + val code = episodeCode(e.season, e.episode)?.let { " · $it" }.orEmpty() + return "${e.friendName} $does ${e.title}$code" } /** What is still downloading in each collection, as a share of its bytes (0 to 100). */ diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/WhoIsWatching.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/WhoIsWatching.kt new file mode 100644 index 00000000..66a494dc --- /dev/null +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/WhoIsWatching.kt @@ -0,0 +1,71 @@ +package studio.kahn.iris.tv.ui.screens.home + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Text +import studio.kahn.iris.tv.data.ProfileView +import studio.kahn.iris.tv.ui.components.FocusColors +import studio.kahn.iris.tv.ui.components.FocusSurface +import studio.kahn.iris.tv.ui.components.Mascot +import studio.kahn.iris.tv.ui.components.Notice +import studio.kahn.iris.tv.ui.components.NoticeLine +import studio.kahn.iris.tv.ui.theme.IrisColor +import studio.kahn.iris.tv.ui.theme.IrisShape +import studio.kahn.iris.tv.ui.theme.IrisSpace +import studio.kahn.iris.tv.ui.theme.IrisType + +private val MASCOT = 128.dp + +/** Who is watching, once per launch of the app when the account has several profiles, each by their mascot; Back stays on the current one (web `ProfileGate`). */ +@Composable +fun WhoIsWatching(profiles: List, notice: Notice?, onPick: (ProfileView) -> Unit) { + val first = remember { FocusRequester() } + val current = profiles.firstOrNull { it.current } ?: profiles.first() + LaunchedEffect(Unit) { runCatching { first.requestFocus() } } + BackHandler { onPick(current) } + Box( + Modifier + .fillMaxSize() + .background(IrisColor.ground), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(IrisSpace.s7)) { + Text("Who’s watching?", style = IrisType.title, color = IrisColor.ink) + FlowRow(Modifier.focusGroup(), horizontalArrangement = Arrangement.spacedBy(IrisSpace.s6)) { + profiles.forEach { p -> + FocusSurface( + onClick = { onPick(p) }, + modifier = if (p == current) Modifier.focusRequester(first) else Modifier, + shape = IrisShape.card, + colors = FocusColors.Quiet, + ) { focused -> + Column( + Modifier.padding(IrisSpace.s4), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(IrisSpace.s3), + ) { + Mascot(p.name, MASCOT) + Text(p.name, style = IrisType.bodyStrong, color = if (focused) IrisColor.ground else IrisColor.ink) + } + } + } + } + NoticeLine(notice) + } + } +} diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt index 81c0b790..34e0c95c 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt @@ -1,5 +1,6 @@ package studio.kahn.iris.tv.ui.screens.library +import studio.kahn.iris.tv.data.HideRequest import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -90,6 +91,13 @@ class HistoryViewModel(private val container: AppContainer) : ViewModel() { play.value = null } + fun hide(group: HistoryGroupUi) { + actions.runSaying("hide:${group.key}") { + container.api().hideFromFriends(HideRequest(infohash = group.lines.first().infohash)) + "${group.title} is hidden from friends." + } + } + fun restore(line: HistoryLine) { val item = history.value?.firstOrNull { historyKey(it) == line.key } ?: return actions.run("restore:${line.key}") { diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/live/LiveLogic.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/live/LiveLogic.kt index 2a2ea2f2..c5984092 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/live/LiveLogic.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/live/LiveLogic.kt @@ -3,7 +3,6 @@ package studio.kahn.iris.tv.ui.screens.live import android.graphics.Bitmap import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color -import androidx.core.graphics.get import androidx.media3.common.PlaybackException import java.text.Normalizer import java.time.OffsetDateTime @@ -11,6 +10,7 @@ import java.util.concurrent.ConcurrentHashMap import studio.kahn.iris.tv.data.LiveChannel import studio.kahn.iris.tv.data.LiveCountry import studio.kahn.iris.tv.data.LiveProgramme +import studio.kahn.iris.tv.ui.format.meanLuminance import studio.kahn.iris.tv.ui.format.plural import studio.kahn.iris.tv.ui.theme.IrisColor @@ -190,33 +190,7 @@ enum class LogoTone(val plate: Color) { /** One luminance pass per logo: the grid recomposes on every guide refresh. */ val logoToneCache = ConcurrentHashMap() -/** Mean relative luminance of the opaque pixels, sampled on a 32×32 grid. */ -fun logoTone(bitmap: Bitmap): LogoTone { - val stepX = maxOf(1, bitmap.width / 32) - val stepY = maxOf(1, bitmap.height / 32) - var luma = 0.0 - var count = 0 - var y = 0 - while (y < bitmap.height) { - var x = 0 - while (x < bitmap.width) { - val px = bitmap[x, y] - if (px ushr 24 and 0xFF > 25) { - luma += 0.2126 * linear(px ushr 16 and 0xFF) + 0.7152 * linear(px ushr 8 and 0xFF) + - 0.0722 * linear(px and 0xFF) - count++ - } - x += stepX - } - y += stepY - } - return toneOf(if (count == 0) null else luma / count) -} - -private fun linear(c: Int): Double { - val v = c / 255.0 - return if (v <= 0.04045) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4) -} +fun logoTone(bitmap: Bitmap): LogoTone = toneOf(meanLuminance(bitmap)) /** Relative luminance of the light and dark plates. */ private const val LIGHT_PLATE = 0.92 diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt index 94ad1654..90e7241d 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt @@ -1,5 +1,10 @@ package studio.kahn.iris.tv.ui.screens.settings +import studio.kahn.iris.tv.ui.components.Mascot +import studio.kahn.iris.tv.data.ProfileView +import studio.kahn.iris.tv.data.PersonView +import studio.kahn.iris.tv.data.HiddenTitleView +import androidx.compose.foundation.layout.RowScope import android.os.Build import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -86,6 +91,17 @@ class SettingsActions( val onPair: (code: String, label: String) -> Unit = { _, _ -> }, val onStopWaiting: () -> Unit = {}, val onRevoke: (DeviceView) -> Unit = {}, + val onAddProfile: (String) -> Unit = {}, + val onSwitchProfile: (ProfileView) -> Unit = {}, + val onRemoveProfile: (ProfileView) -> Unit = {}, + val onFindFriends: (String) -> Unit = {}, + val onAskFriend: (PersonView) -> Unit = {}, + val onAcceptFriend: (PersonView) -> Unit = {}, + val onDeclineFriend: (PersonView) -> Unit = {}, + val onRemoveFriend: (PersonView) -> Unit = {}, + val onTakeBack: (PersonView) -> Unit = {}, + val onShare: (Boolean) -> Unit = {}, + val onShowToFriends: (HiddenTitleView) -> Unit = {}, val onChangePassword: (current: String, next: String) -> Unit = { _, _ -> }, val onSignOut: () -> Unit = {}, val onInterfaceSize: (InterfaceSize) -> Unit = {}, @@ -118,6 +134,8 @@ fun SettingsSectionContent( ) { when (section) { SettingsSection.You -> YouSection(state, actions) + SettingsSection.Profiles -> ProfilesSection(state, actions) + SettingsSection.Friends -> FriendsSection(state, actions) SettingsSection.Playback -> PlaybackSection(state, actions) SettingsSection.Recommendations -> RecommendationsSection(state, actions) SettingsSection.Devices -> DevicesSection(state, actions, now) @@ -148,7 +166,8 @@ private fun YouSection(state: SettingsUiState, actions: SettingsActions) { SettingsGroup("You", "Shown to the household, as in “added by”. Your email stays private.") { Loaded(state.account, actions.onRetry) { user -> SettingRow("Display name", user.displayName, onClick = { actions.onOpen(SettingsDialog.Rename) }) - val fromEmail = nameFromEmail(user.email) + val main = user.accountId == null + val fromEmail = if (main) nameFromEmail(user.email) else "" if (fromEmail.isNotEmpty() && fromEmail != user.displayName) { ActionButton( "Use “$fromEmail” from your email", @@ -160,13 +179,89 @@ private fun YouSection(state: SettingsUiState, actions: SettingsActions) { } NoticeLine(state.outcome.notice(SettingsSection.You)) Column { - FactRow("Email", user.email) + if (main) FactRow("Email", user.email) FactRow("Role", if (user.isAdmin) "Admin" else "Member") } } } } +@Composable +private fun ProfilesSection(state: SettingsUiState, actions: SettingsActions) { + SettingsGroup("Profiles", "For people sharing this account: each profile keeps its own history, watchlist and preferences.") { + ActionButton("Add a profile", { actions.onOpen(SettingsDialog.AddProfile) }, style = ActionStyle.Secondary) + NoticeLine(state.outcome.notice(SettingsSection.Profiles)) + Loaded(state.profiles, actions.onRetry) { profiles -> + profiles.forEach { p -> + val words = listOfNotNull("Main profile".takeIf { p.main }, "In use".takeIf { p.current }).joinToString(" · ") + PersonRow(p.name, words.ifEmpty { null }) { + if (!p.current) { + ActionButton("Switch", { actions.onSwitchProfile(p) }, style = ActionStyle.Secondary, size = ActionSize.Small, busy = state.busy == Busy.PROFILE) + } + if (!p.main && !p.current) { + ActionButton("Remove", { actions.onOpen(SettingsDialog.RemoveProfile(p)) }, style = ActionStyle.Secondary, size = ActionSize.Small) + } + } + } + } + } +} + +/** A person or a title, and what can be done about it. */ +@Composable +private fun PersonRow(name: String, facts: String? = null, person: Boolean = true, actions: @Composable RowScope.() -> Unit) { + Row(horizontalArrangement = Arrangement.spacedBy(IrisSpace.s4), verticalAlignment = Alignment.CenterVertically) { + if (person) Mascot(name, IrisSize.control) + Column(Modifier.weight(1f)) { + Text(name, style = IrisType.body, color = IrisColor.ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + facts?.let { Text(it, style = IrisType.meta, color = IrisColor.inkMuted) } + } + actions() + } +} + +@Composable +private fun FriendsSection(state: SettingsUiState, actions: SettingsActions) { + val small: @Composable (String, Boolean, () -> Unit) -> Unit = { words, primary, onClick -> + ActionButton( + words, + onClick, + style = if (primary) ActionStyle.Primary else ActionStyle.Secondary, + size = ActionSize.Small, + busy = state.busy == Busy.FRIENDS, + ) + } + SettingsGroup("Friends", "Friends see what you add to the library. What you watch stays private until you share it below.") { + ActionButton("Find someone by name", { actions.onOpen(SettingsDialog.FindFriend) }, style = ActionStyle.Secondary) + NoticeLine(state.outcome.notice(SettingsSection.Friends)) + state.found?.let { found -> + if (found.isEmpty()) Text("Nobody new by that name.", style = IrisType.body, color = IrisColor.inkMuted) + found.forEach { p -> PersonRow(p.name) { small("Ask", true) { actions.onAskFriend(p) } } } + } + Loaded(state.friends, actions.onRetry) { friends -> + if (friends.incoming.isNotEmpty()) ChoiceLabel("Asked you", "Declining tells them nothing.") + friends.incoming.forEach { p -> + PersonRow(p.name) { + small("Accept", true) { actions.onAcceptFriend(p) } + small("Decline", false) { actions.onDeclineFriend(p) } + } + } + friends.friends.forEach { p -> + PersonRow(p.name) { small("Remove", false) { actions.onOpen(SettingsDialog.RemoveFriend(p)) } } + } + if (friends.outgoing.isNotEmpty()) ChoiceLabel("Waiting for an answer", "They can still say yes.") + friends.outgoing.forEach { p -> PersonRow(p.name) { small("Take back", false) { actions.onTakeBack(p) } } } + ChoiceLabel("What you watch", "Your friends only see it once you share it.") + FlowRow(horizontalArrangement = Arrangement.spacedBy(IrisSpace.s2)) { + Pill("Keep it private", selected = !friends.shareWatching, onClick = { actions.onShare(false) }) + Pill("Share with friends", selected = friends.shareWatching, onClick = { actions.onShare(true) }) + } + if (friends.hidden.isNotEmpty()) ChoiceLabel("Hidden from friends", "Neither what you watch of these nor what you add.") + friends.hidden.forEach { h -> PersonRow(h.title, person = false) { small("Show again", false) { actions.onShowToFriends(h) } } } + } + } +} + @Composable private fun PlaybackSection(state: SettingsUiState, actions: SettingsActions) { SettingsGroup("Playback", "The languages a film or an episode starts in, on every device, when it offers them.") { diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt index cd6ffc67..f6dff1c6 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt @@ -1,5 +1,14 @@ package studio.kahn.iris.tv.ui.screens.settings +import studio.kahn.iris.tv.data.UnhideRequest +import studio.kahn.iris.tv.data.SwitchProfileRequest +import studio.kahn.iris.tv.data.SharingRequest +import studio.kahn.iris.tv.data.ProfileView +import studio.kahn.iris.tv.data.ProfileChoice +import studio.kahn.iris.tv.data.PersonView +import studio.kahn.iris.tv.data.HiddenTitleView +import studio.kahn.iris.tv.data.FriendsOverview +import studio.kahn.iris.tv.data.CreateProfileRequest import studio.kahn.iris.tv.ui.format.languagesPhrase import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel @@ -39,6 +48,8 @@ import studio.kahn.iris.tv.ui.components.Notice /** The parts of Settings, in rail order. */ enum class SettingsSection(val label: String) { You("You"), + Profiles("Profiles"), + Friends("Friends"), Playback("Playback"), Recommendations("Recommendations"), Devices("Devices"), @@ -75,6 +86,10 @@ sealed interface SettingsDialog { data object Audio : SettingsDialog data object Subtitles : SettingsDialog data class Revoke(val device: DeviceView) : SettingsDialog + data object AddProfile : SettingsDialog + data class RemoveProfile(val profile: ProfileView) : SettingsDialog + data object FindFriend : SettingsDialog + data class RemoveFriend(val person: PersonView) : SettingsDialog /** Another server means signing out here and pairing there. */ data object ChangeServer : SettingsDialog } @@ -103,6 +118,10 @@ data class SettingsUiState( val draft: Picks? = null, val devices: Loadable> = Loadable.Loading, val passkeys: Loadable> = Loadable.Loading, + val profiles: Loadable> = Loadable.Loading, + val friends: Loadable = Loadable.Loading, + /** The last search for someone to ask; null before any. */ + val found: List? = null, val dialog: SettingsDialog? = null, val dialogError: DialogError? = null, /** The action running ([Busy]), its control shows it. */ @@ -116,6 +135,12 @@ data class SettingsUiState( /** How big the interface draws on this device. */ val interfaceSize: InterfaceSize = InterfaceSize.Default, ) { + /** The sections of this profile: the password and passkeys stay with the main one. */ + val sections: List + get() = SettingsSection.entries.filter { + account.valueOrNull?.accountId == null || (it != SettingsSection.Passkeys && it != SettingsSection.Password) + } + val recoDirty: Boolean get() = draft != null && reco.valueOrNull?.let { Picks.of(it.saved) } != draft } @@ -130,6 +155,8 @@ object Busy { const val PAIR = "pair" const val PASSWORD = "password" const val SIGN_OUT = "sign-out" + const val PROFILE = "profile" + const val FRIENDS = "friends" fun revoke(device: DeviceView) = "revoke:${device.jti}" } @@ -175,6 +202,8 @@ class SettingsViewModel(private val container: AppContainer) : ViewModel() { launch { readPlayback() } launch { readReco() } launch { readDevices() } + launch { readProfiles() } + launch { readFriends() } launch { val next = load(mutable.value.passkeys) { absentAs(emptyList()) { api().listPasskeys() } } mutable.update { it.copy(passkeys = next) } @@ -197,6 +226,16 @@ class SettingsViewModel(private val container: AppContainer) : ViewModel() { mutable.update { it.copy(devices = next) } } + private suspend fun readProfiles() { + val next = load(mutable.value.profiles) { api().profiles() } + mutable.update { it.copy(profiles = next) } + } + + private suspend fun readFriends() { + val next = load(mutable.value.friends) { api().friends() } + mutable.update { it.copy(friends = next) } + } + private suspend fun readReco() { val next = load(mutable.value.reco) { val a = api() @@ -417,12 +456,100 @@ class SettingsViewModel(private val container: AppContainer) : ViewModel() { } } + fun addProfile(name: String) { + val clean = name.trim() + if (clean.isEmpty()) { + mutable.update { it.copy(dialogError = DialogError("Enter a name.")) } + return + } + act(Busy.PROFILE, SettingsSection.Profiles, inDialog = true) { api -> + val next = api.createProfile(CreateProfileRequest(name = clean)) + mutable.update { it.copy(profiles = Loadable.Ready(next)) } + "Profile $clean added." + } + } + + fun switchProfile(profile: ProfileView) { + act(Busy.PROFILE, SettingsSection.Profiles) { api -> + api.switchProfile(SwitchProfileRequest(profileId = profile.id)) + ProfileChoice.changed() + mutable.update { it.copy(nameChanges = it.nameChanges + 1, found = null) } + refresh() + "On ${profile.name}." + } + } + + fun removeProfile(profile: ProfileView) { + act(Busy.PROFILE, SettingsSection.Profiles, inDialog = true) { api -> + api.removeProfile(profile.id.toString()) + readProfiles() + "Profile ${profile.name} removed." + } + } + + /** Answered, then the friends read again. */ + private fun friendsAct(inDialog: Boolean = false, block: suspend (IrisApi) -> String) { + act(Busy.FRIENDS, SettingsSection.Friends, inDialog = inDialog) { api -> + block(api).also { readFriends() } + } + } + + fun findFriends(name: String) { + val clean = name.trim() + if (clean.length < 2) { + mutable.update { it.copy(dialogError = DialogError("Enter two letters at least.")) } + return + } + act(Busy.FRIENDS, SettingsSection.Friends, inDialog = true) { api -> + val found = api.searchFriends(clean) + mutable.update { it.copy(found = found) } + null + } + } + + fun askFriend(person: PersonView) = friendsAct { api -> + api.askFriend(person.id.toString()) + mutable.update { s -> s.copy(found = s.found?.filter { it.id != person.id }) } + "Request sent to ${person.name}." + } + + fun acceptFriend(person: PersonView) = friendsAct { api -> + api.acceptFriend(person.id.toString()) + "You and ${person.name} are friends." + } + + fun declineFriend(person: PersonView) = friendsAct { api -> + api.declineFriend(person.id.toString()) + "Request from ${person.name} declined." + } + + fun removeFriend(person: PersonView) = friendsAct(inDialog = true) { api -> + api.removeFriend(person.id.toString()) + "${person.name} removed." + } + + fun takeBack(person: PersonView) = friendsAct { api -> + api.removeFriend(person.id.toString()) + "Request to ${person.name} taken back." + } + + fun setSharing(on: Boolean) = friendsAct { api -> + api.setSharing(SharingRequest(shareWatching = on)) + if (on) "Friends can see what you watch." else "What you watch stays private." + } + + fun showToFriends(title: HiddenTitleView) = friendsAct { api -> + api.showToFriends(UnhideRequest(collectionId = title.collectionId, infohash = title.infohash)) + "${title.title} is visible to friends again." + } + fun signOut() { if (mutable.value.busy != null) return mutable.update { it.copy(busy = Busy.SIGN_OUT) } viewModelScope.launch { // Best effort: the session is forgotten here whether the server heard or not. bestEffort { api().logout() } + ProfileChoice.forget() container.sessionStore.clear() mutable.update { it.copy(busy = null, signedOut = true) } } diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/theme/Color.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/theme/Color.kt index d4c4540f..584c494f 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/theme/Color.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/theme/Color.kt @@ -39,6 +39,13 @@ object IrisColor { /** The fallback tile behind a poster or still with no artwork, and its title. */ val art = Color(0xFF24323F) val artInk = Color(0xFFF2F0EA) + + /** A person's mascot: its tiles (web `--mascot-*`) and the blush of the Iris mark. */ + val mascots = listOf( + Color(0xFF0E5E68), Color(0xFF5B3A6E), Color(0xFF9A4A36), Color(0xFF2F6B3C), + Color(0xFF34508C), Color(0xFF8A6A1F), Color(0xFF8E3F5C), Color(0xFF4A5666), + ) + val mascotBlush = Color(0xD9E6A49A) /** A provider tag laid over artwork. */ val artScrim = Color(0xC70E1218) diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/HomeScreenshots.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/HomeScreenshots.kt index e46b22da..5328df0d 100644 --- a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/HomeScreenshots.kt +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/HomeScreenshots.kt @@ -1,5 +1,9 @@ package studio.kahn.iris.tv.screenshot +import studio.kahn.iris.tv.ui.screens.home.WhoIsWatching +import studio.kahn.iris.tv.ui.screens.home.FriendLine +import studio.kahn.iris.tv.data.ProfileView +import java.util.UUID import studio.kahn.iris.tv.data.InterfaceSize import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -157,6 +161,40 @@ class HomeScreenshots { Home(home.copy(hero = hero.copy(title = "May I Help You and the Sisters of the Long Night", meta = "Season 1 · Episode 3 · 41 min left"))) } + @Test + fun homeReleaseNote() = shots.snapEverySize("home_release_note") { + Home(home.copy(releaseNote = Notice("Updated to 1.7.0 · Profiles for shared accounts, and friends on the home."))) + } + + @Test + fun homeFriends() = shots.snap("home_friends") { + Home( + home.copy( + hero = null, + heroPending = false, + rightNow = Loadable.Ready(listOf("2 friend requests")), + friends = listOf( + FriendLine("a", "Muriel", "Muriel is watching Severance · S2:E4", null, "c1"), + FriendLine("b", "Vincent", "Vincent added Dune: Part Two", "2 Oct", "c2"), + FriendLine("c", "Muriel", "Muriel watched The Bear · S3:E1", "1 Oct", "c3"), + ), + ), + ) + } + + @Test + fun whoIsWatching() = shots.snapEverySize("home_who_is_watching") { + WhoIsWatching( + listOf( + ProfileView(current = true, id = UUID(0, 1), main = true, name = "Vincent"), + ProfileView(current = false, id = UUID(0, 2), main = false, name = "Muriel"), + ProfileView(current = false, id = UUID(0, 3), main = false, name = "Les enfants"), + ), + notice = null, + onPick = {}, + ) + } + @Test fun homeUpdate() = shots.snapEverySize("home_update") { Home(home, UpdateNotice("1.0.4", "1.0.2", null)) } diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt index daf3fcba..50af74c2 100644 --- a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt @@ -1,5 +1,9 @@ package studio.kahn.iris.tv.screenshot +import studio.kahn.iris.tv.data.ProfileView +import studio.kahn.iris.tv.data.PersonView +import studio.kahn.iris.tv.data.HiddenTitleView +import studio.kahn.iris.tv.data.FriendsOverview import studio.kahn.iris.tv.data.InterfaceSize import androidx.compose.runtime.Composable import java.io.File @@ -77,6 +81,17 @@ class SettingsScreenshots { devices = Loadable.Ready(devices), passkeys = Loadable.Ready(passkeys), ) + private val family = listOf( + ProfileView(current = true, id = UUID(0, 1), main = true, name = "Vincent"), + ProfileView(current = false, id = UUID(0, 7), main = false, name = "Muriel"), + ) + private val friends = FriendsOverview( + friends = listOf(PersonView(UUID(0, 8), "Camille"), PersonView(UUID(0, 9), "Théo")), + hidden = listOf(HiddenTitleView("Love Island")), + incoming = listOf(PersonView(UUID(0, 10), "Sarah")), + outgoing = listOf(PersonView(UUID(0, 11), "Hugo")), + shareWatching = true, + ) private val update = UpdateState(installed = "1.5.0", installedCode = 33, latest = AppUpdater.VersionStatus.UpdateAvailable("1.6.0"), checking = false) private val facts = TvFacts("1.5.0 (33) · build 20261006-1400", "Google Chromecast HD", "14 (API 34)") @@ -85,6 +100,16 @@ class SettingsScreenshots { SettingsContent(state, up, SettingsActions(), initialSection = section, now = now, facts = facts) } + @Test + fun profiles() = shots.snapEverySize("settings_profiles") { + Settings(ready.copy(profiles = Loadable.Ready(family)), SettingsSection.Profiles) + } + + @Test + fun friends() = shots.snapEverySize("settings_friends") { + Settings(ready.copy(friends = Loadable.Ready(friends), found = listOf(PersonView(UUID(0, 12), "Vincent"))), SettingsSection.Friends) + } + @Test fun you() = shots.snapEverySize("settings_you") { Settings(section = SettingsSection.You) } diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/ShellScreenshots.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/ShellScreenshots.kt index 1e248edf..68a6a89c 100644 --- a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/ShellScreenshots.kt +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/ShellScreenshots.kt @@ -1,5 +1,17 @@ package studio.kahn.iris.tv.screenshot +import studio.kahn.iris.tv.ui.screens.home.heroBackdrop +import studio.kahn.iris.tv.ui.screens.home.HERO_ART_WIDTH +import studio.kahn.iris.tv.ui.screens.home.FADE_LEFT +import studio.kahn.iris.tv.ui.nav.HeaderBackdrop +import androidx.compose.ui.graphics.Brush +import studio.kahn.iris.tv.ui.nav.ShellBackdrop +import studio.kahn.iris.tv.ui.nav.LocalShellBackdrop +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -64,5 +76,45 @@ class ShellScreenshots { TopLevelShell(TopTab.Library, "Leonard", updateAvailable = true, onSelect = {}, onAccount = {}) {} } + /** The header over [still]: the whole screen, or where the hero's still really sits, faded. */ + @Composable + private fun OverStill(still: Color, backdrop: HeaderBackdrop?, hero: Boolean = false) { + TopLevelShell(TopTab.Home, "Uplg", onSelect = {}, onAccount = {}, headerOverContent = true) { + ShellBackdrop { + if (hero) { + Box(Modifier.size(IrisLayout.current.width * HERO_ART_WIDTH, IrisLayout.current.height).background(still)) { + Box( + Modifier + .matchParentSize() + .background(Brush.horizontalGradient(0f to IrisColor.ground, FADE_LEFT to IrisColor.ground.copy(alpha = 0f))), + ) + } + } else { + Box(Modifier.size(IrisLayout.current.width, IrisLayout.current.height).background(still)) + } + } + val holder = LocalShellBackdrop.current + SideEffect { holder?.header = backdrop } + } + } + + private fun all(luminance: Double) = HeaderBackdrop(List(48) { luminance }) + + @Test + fun headerBeforeOnALightStill() = shots.snap("shell_header_over_light_before") { OverStill(LIGHT_STILL, null) } + + @Test + fun headerOnALightStill() = shots.snap("shell_header_over_light") { OverStill(LIGHT_STILL, all(0.8)) } + + @Test + fun headerOnAMidStill() = shots.snap("shell_header_over_mid") { OverStill(MID_STILL, all(0.12)) } + + // Where the hero's still really sits: the logo stays on the ground, the name goes dark. + @Test + fun headerOnAHeroStill() = shots.snap("shell_header_over_hero") { OverStill(LIGHT_STILL, heroBackdrop { _, _ -> 0.8 }, hero = true) } + private val outdated = UpdateState(installed = "1.5.0", latest = AppUpdater.VersionStatus.UpdateAvailable("1.6.0")) } + +private val LIGHT_STILL = Color(0xFFF2E8D5) +private val MID_STILL = Color(0xFF5B6B78) diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/components/MascotTest.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/components/MascotTest.kt new file mode 100644 index 00000000..3e05bc05 --- /dev/null +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/components/MascotTest.kt @@ -0,0 +1,22 @@ +package studio.kahn.iris.tv.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The vectors of `packages/iris-api/src/mascot.test.ts`: one name, one mascot, on the web and here. */ +class MascotTest { + @Test + fun theVectorsSharedWithTheWeb() { + assertEquals(4095504808L, mascotHash("Vincent")) + assertEquals(MascotParts(0, 1, 1, 1, 2), mascotOf("Vincent")) + assertEquals(MascotParts(3, 1, 1, 2, 1), mascotOf("Muriel")) + assertEquals(MascotParts(1, 3, 3, 1, 2), mascotOf("Uplg")) + assertEquals(MascotParts(6, 0, 3, 2, 2), mascotOf("Théo")) + assertEquals(MascotParts(5, 0, 4, 0, 0), mascotOf("")) + } + + @Test + fun caseAndSpacesDoNotChangeIt() { + assertEquals(mascotOf("théo"), mascotOf(" Théo ")) + } +} diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/nav/HeaderBackdropTest.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/nav/HeaderBackdropTest.kt new file mode 100644 index 00000000..faf8185a --- /dev/null +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/nav/HeaderBackdropTest.kt @@ -0,0 +1,37 @@ +package studio.kahn.iris.tv.ui.nav + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import studio.kahn.iris.tv.ui.screens.home.heroBackdrop +import studio.kahn.iris.tv.ui.screens.live.LogoTone + +class HeaderBackdropTest { + @Test + fun aBrightStillDarkensOnlyWhatSitsOnIt() { + val backdrop = heroBackdrop { _, _ -> 0.8 } + assertNull("the logo sits on the ground", backdrop.inkAt(0.05f, 0.1f)) + assertEquals("the name sits on the still", LogoTone.Dark.plate, backdrop.inkAt(0.9f, 0.95f)) + } + + @Test + fun aDarkStillKeepsTheUsualWords() { + assertNull(heroBackdrop { _, _ -> 0.02 }.inkAt(0.9f, 0.95f)) + } + + @Test + fun aMidStillTakesTheLightWords() { + assertEquals(LogoTone.Light.plate, heroBackdrop { _, _ -> 0.12 }.inkAt(0.9f, 0.95f)) + } + + @Test + fun theStartOfTheFadeReadsAsTheGround() { + assertNull(heroBackdrop { _, _ -> 0.8 }.inkAt(0.44f, 0.46f)) + } + + @Test + fun anUnreadableStillChangesNothing() { + assertNull(HeaderBackdrop(emptyList()).inkAt(0f, 1f)) + assertNull(heroBackdrop { _, _ -> null }.inkAt(0.9f, 0.95f)) + } +} diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt index 50fa6b3e..c899b9d5 100644 --- a/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt @@ -7,8 +7,11 @@ import org.junit.Assert.assertNull import org.junit.Test import studio.kahn.iris.tv.data.ContinueWatchingItem import studio.kahn.iris.tv.data.DiskSpace +import studio.kahn.iris.tv.data.FriendAction +import studio.kahn.iris.tv.data.FriendEvent import studio.kahn.iris.tv.data.HomeSummary import studio.kahn.iris.tv.data.MediaKind +import studio.kahn.iris.tv.data.ReleaseNote import studio.kahn.iris.tv.data.TorrentState import studio.kahn.iris.tv.data.TorrentView @@ -25,6 +28,35 @@ class HomeWordsTest { assertEquals(emptyList(), rightNow(HomeSummary(0, 0.0, 0, 0))) } + @Test + fun friendInASentence() { + val e = FriendEvent( + action = FriendAction.watching, + at = OffsetDateTime.parse("2026-10-05T20:00:00Z"), + friendId = UUID.randomUUID(), + friendName = "Muriel", + title = "Severance", + season = 2, + episode = 4, + ) + assertEquals("Muriel is watching Severance · S2:E4", friendLine(e)) + assertEquals("Muriel added Severance", friendLine(e.copy(action = FriendAction.added, season = null, episode = null))) + } + + @Test + fun friendRequestsAreSaid() { + assertEquals(listOf("2 friend requests"), rightNow(HomeSummary(0, 0.0, 0, 0, friendRequests = 2))) + } + + @Test + fun updateSentenceAfterAnUpdateOnly() { + val release = ReleaseNote(version = "2.0.0", note = "Two more trackers.") + assertEquals("Two more trackers.", noteDue(release, "1.9.0")) + assertNull(noteDue(release, "2.0.0")) + assertNull(noteDue(release, null)) + assertNull(noteDue(ReleaseNote(version = "2.0.0"), "1.9.0")) + } + @Test fun downloadsPerCollectionByBytes() { val a = UUID.randomUUID() diff --git a/android-tv/app/src/test/screenshots/discover_mood_phone800.png b/android-tv/app/src/test/screenshots/discover_mood_phone800.png index 292a4d42..a14cb234 100644 Binary files a/android-tv/app/src/test/screenshots/discover_mood_phone800.png and b/android-tv/app/src/test/screenshots/discover_mood_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/discover_mood_phone915.png b/android-tv/app/src/test/screenshots/discover_mood_phone915.png index a9adff0b..c518cc62 100644 Binary files a/android-tv/app/src/test/screenshots/discover_mood_phone915.png and b/android-tv/app/src/test/screenshots/discover_mood_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/discover_mood_tv.png b/android-tv/app/src/test/screenshots/discover_mood_tv.png index 7c1435ec..68f122e2 100644 Binary files a/android-tv/app/src/test/screenshots/discover_mood_tv.png and b/android-tv/app/src/test/screenshots/discover_mood_tv.png differ diff --git a/android-tv/app/src/test/screenshots/discover_phone800.png b/android-tv/app/src/test/screenshots/discover_phone800.png index 92827cb9..30eb86ee 100644 Binary files a/android-tv/app/src/test/screenshots/discover_phone800.png and b/android-tv/app/src/test/screenshots/discover_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/discover_phone915.png b/android-tv/app/src/test/screenshots/discover_phone915.png index e49e5157..f56e386b 100644 Binary files a/android-tv/app/src/test/screenshots/discover_phone915.png and b/android-tv/app/src/test/screenshots/discover_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/discover_tv.png b/android-tv/app/src/test/screenshots/discover_tv.png index 00d7e243..fdd9237f 100644 Binary files a/android-tv/app/src/test/screenshots/discover_tv.png and b/android-tv/app/src/test/screenshots/discover_tv.png differ diff --git a/android-tv/app/src/test/screenshots/header.png b/android-tv/app/src/test/screenshots/header.png index ea4edf86..8b818c7c 100644 Binary files a/android-tv/app/src/test/screenshots/header.png and b/android-tv/app/src/test/screenshots/header.png differ diff --git a/android-tv/app/src/test/screenshots/header_focused.png b/android-tv/app/src/test/screenshots/header_focused.png index a2c890f0..d6ea31ab 100644 Binary files a/android-tv/app/src/test/screenshots/header_focused.png and b/android-tv/app/src/test/screenshots/header_focused.png differ diff --git a/android-tv/app/src/test/screenshots/history_phone800.png b/android-tv/app/src/test/screenshots/history_phone800.png index 2d392746..ece6d1a4 100644 Binary files a/android-tv/app/src/test/screenshots/history_phone800.png and b/android-tv/app/src/test/screenshots/history_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/history_phone915.png b/android-tv/app/src/test/screenshots/history_phone915.png index b83531fb..fc822699 100644 Binary files a/android-tv/app/src/test/screenshots/history_phone915.png and b/android-tv/app/src/test/screenshots/history_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/history_tv.png b/android-tv/app/src/test/screenshots/history_tv.png index 6f74bebc..d71b1f7b 100644 Binary files a/android-tv/app/src/test/screenshots/history_tv.png and b/android-tv/app/src/test/screenshots/history_tv.png differ diff --git a/android-tv/app/src/test/screenshots/home_empty.png b/android-tv/app/src/test/screenshots/home_empty.png index 0a30ab0f..6e638616 100644 Binary files a/android-tv/app/src/test/screenshots/home_empty.png and b/android-tv/app/src/test/screenshots/home_empty.png differ diff --git a/android-tv/app/src/test/screenshots/home_friends.png b/android-tv/app/src/test/screenshots/home_friends.png new file mode 100644 index 00000000..10db026f Binary files /dev/null and b/android-tv/app/src/test/screenshots/home_friends.png differ diff --git a/android-tv/app/src/test/screenshots/home_largest_phone800.png b/android-tv/app/src/test/screenshots/home_largest_phone800.png index c40fe545..4c1811f4 100644 Binary files a/android-tv/app/src/test/screenshots/home_largest_phone800.png and b/android-tv/app/src/test/screenshots/home_largest_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/home_largest_phone915.png b/android-tv/app/src/test/screenshots/home_largest_phone915.png index 92356665..4d6169f6 100644 Binary files a/android-tv/app/src/test/screenshots/home_largest_phone915.png and b/android-tv/app/src/test/screenshots/home_largest_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/home_largest_tv.png b/android-tv/app/src/test/screenshots/home_largest_tv.png index fe396874..ff977e30 100644 Binary files a/android-tv/app/src/test/screenshots/home_largest_tv.png and b/android-tv/app/src/test/screenshots/home_largest_tv.png differ diff --git a/android-tv/app/src/test/screenshots/home_long_title_phone800.png b/android-tv/app/src/test/screenshots/home_long_title_phone800.png index e710734a..b621be6b 100644 Binary files a/android-tv/app/src/test/screenshots/home_long_title_phone800.png and b/android-tv/app/src/test/screenshots/home_long_title_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/home_long_title_phone915.png b/android-tv/app/src/test/screenshots/home_long_title_phone915.png index 9b1a52d1..e1bb33d6 100644 Binary files a/android-tv/app/src/test/screenshots/home_long_title_phone915.png and b/android-tv/app/src/test/screenshots/home_long_title_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/home_long_title_tv.png b/android-tv/app/src/test/screenshots/home_long_title_tv.png index ef00bc5a..0d3b8ddd 100644 Binary files a/android-tv/app/src/test/screenshots/home_long_title_tv.png and b/android-tv/app/src/test/screenshots/home_long_title_tv.png differ diff --git a/android-tv/app/src/test/screenshots/home_notice.png b/android-tv/app/src/test/screenshots/home_notice.png index cb10d4a3..d4d34680 100644 Binary files a/android-tv/app/src/test/screenshots/home_notice.png and b/android-tv/app/src/test/screenshots/home_notice.png differ diff --git a/android-tv/app/src/test/screenshots/home_phone800.png b/android-tv/app/src/test/screenshots/home_phone800.png index cea63a8c..0683f4a5 100644 Binary files a/android-tv/app/src/test/screenshots/home_phone800.png and b/android-tv/app/src/test/screenshots/home_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/home_phone915.png b/android-tv/app/src/test/screenshots/home_phone915.png index 4eabc439..7365611e 100644 Binary files a/android-tv/app/src/test/screenshots/home_phone915.png and b/android-tv/app/src/test/screenshots/home_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/home_release_note_phone800.png b/android-tv/app/src/test/screenshots/home_release_note_phone800.png new file mode 100644 index 00000000..51cc9105 Binary files /dev/null and b/android-tv/app/src/test/screenshots/home_release_note_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/home_release_note_phone915.png b/android-tv/app/src/test/screenshots/home_release_note_phone915.png new file mode 100644 index 00000000..76e68ae2 Binary files /dev/null and b/android-tv/app/src/test/screenshots/home_release_note_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/home_release_note_tv.png b/android-tv/app/src/test/screenshots/home_release_note_tv.png new file mode 100644 index 00000000..0a244d8f Binary files /dev/null and b/android-tv/app/src/test/screenshots/home_release_note_tv.png differ diff --git a/android-tv/app/src/test/screenshots/home_tv.png b/android-tv/app/src/test/screenshots/home_tv.png index 04f3d26c..99093eb9 100644 Binary files a/android-tv/app/src/test/screenshots/home_tv.png and b/android-tv/app/src/test/screenshots/home_tv.png differ diff --git a/android-tv/app/src/test/screenshots/home_update_downloading_phone800.png b/android-tv/app/src/test/screenshots/home_update_downloading_phone800.png index d214784c..22d2972b 100644 Binary files a/android-tv/app/src/test/screenshots/home_update_downloading_phone800.png and b/android-tv/app/src/test/screenshots/home_update_downloading_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/home_update_downloading_phone915.png b/android-tv/app/src/test/screenshots/home_update_downloading_phone915.png index 005bee7f..15ab63c5 100644 Binary files a/android-tv/app/src/test/screenshots/home_update_downloading_phone915.png and b/android-tv/app/src/test/screenshots/home_update_downloading_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/home_update_downloading_tv.png b/android-tv/app/src/test/screenshots/home_update_downloading_tv.png index 9c2fce31..e9d95680 100644 Binary files a/android-tv/app/src/test/screenshots/home_update_downloading_tv.png and b/android-tv/app/src/test/screenshots/home_update_downloading_tv.png differ diff --git a/android-tv/app/src/test/screenshots/home_update_failed.png b/android-tv/app/src/test/screenshots/home_update_failed.png index b1014b0d..8c226046 100644 Binary files a/android-tv/app/src/test/screenshots/home_update_failed.png and b/android-tv/app/src/test/screenshots/home_update_failed.png differ diff --git a/android-tv/app/src/test/screenshots/home_update_phone800.png b/android-tv/app/src/test/screenshots/home_update_phone800.png index 3e66e8b9..8f30f188 100644 Binary files a/android-tv/app/src/test/screenshots/home_update_phone800.png and b/android-tv/app/src/test/screenshots/home_update_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/home_update_phone915.png b/android-tv/app/src/test/screenshots/home_update_phone915.png index d931093e..d6a12612 100644 Binary files a/android-tv/app/src/test/screenshots/home_update_phone915.png and b/android-tv/app/src/test/screenshots/home_update_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/home_update_tv.png b/android-tv/app/src/test/screenshots/home_update_tv.png index 573b2695..f439fbd0 100644 Binary files a/android-tv/app/src/test/screenshots/home_update_tv.png and b/android-tv/app/src/test/screenshots/home_update_tv.png differ diff --git a/android-tv/app/src/test/screenshots/home_who_is_watching_phone800.png b/android-tv/app/src/test/screenshots/home_who_is_watching_phone800.png new file mode 100644 index 00000000..7c9ce041 Binary files /dev/null and b/android-tv/app/src/test/screenshots/home_who_is_watching_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/home_who_is_watching_phone915.png b/android-tv/app/src/test/screenshots/home_who_is_watching_phone915.png new file mode 100644 index 00000000..b89c2858 Binary files /dev/null and b/android-tv/app/src/test/screenshots/home_who_is_watching_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/home_who_is_watching_tv.png b/android-tv/app/src/test/screenshots/home_who_is_watching_tv.png new file mode 100644 index 00000000..dda1e6d9 Binary files /dev/null and b/android-tv/app/src/test/screenshots/home_who_is_watching_tv.png differ diff --git a/android-tv/app/src/test/screenshots/library_downloads_phone800.png b/android-tv/app/src/test/screenshots/library_downloads_phone800.png index 1a74d51f..86a40641 100644 Binary files a/android-tv/app/src/test/screenshots/library_downloads_phone800.png and b/android-tv/app/src/test/screenshots/library_downloads_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/library_downloads_phone915.png b/android-tv/app/src/test/screenshots/library_downloads_phone915.png index aa0ce689..52aa893a 100644 Binary files a/android-tv/app/src/test/screenshots/library_downloads_phone915.png and b/android-tv/app/src/test/screenshots/library_downloads_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/library_downloads_tv.png b/android-tv/app/src/test/screenshots/library_downloads_tv.png index 5aa1a682..68aa9066 100644 Binary files a/android-tv/app/src/test/screenshots/library_downloads_tv.png and b/android-tv/app/src/test/screenshots/library_downloads_tv.png differ diff --git a/android-tv/app/src/test/screenshots/library_title_menu.png b/android-tv/app/src/test/screenshots/library_title_menu.png index 4948721e..b5e4b075 100644 Binary files a/android-tv/app/src/test/screenshots/library_title_menu.png and b/android-tv/app/src/test/screenshots/library_title_menu.png differ diff --git a/android-tv/app/src/test/screenshots/library_titles_empty.png b/android-tv/app/src/test/screenshots/library_titles_empty.png index 2f97dbae..7923bde1 100644 Binary files a/android-tv/app/src/test/screenshots/library_titles_empty.png and b/android-tv/app/src/test/screenshots/library_titles_empty.png differ diff --git a/android-tv/app/src/test/screenshots/library_titles_failed.png b/android-tv/app/src/test/screenshots/library_titles_failed.png index 241bd624..fea1bfe7 100644 Binary files a/android-tv/app/src/test/screenshots/library_titles_failed.png and b/android-tv/app/src/test/screenshots/library_titles_failed.png differ diff --git a/android-tv/app/src/test/screenshots/library_titles_loading.png b/android-tv/app/src/test/screenshots/library_titles_loading.png index e23d6b08..0f21948b 100644 Binary files a/android-tv/app/src/test/screenshots/library_titles_loading.png and b/android-tv/app/src/test/screenshots/library_titles_loading.png differ diff --git a/android-tv/app/src/test/screenshots/library_titles_phone800.png b/android-tv/app/src/test/screenshots/library_titles_phone800.png index 11e568d1..c9886a55 100644 Binary files a/android-tv/app/src/test/screenshots/library_titles_phone800.png and b/android-tv/app/src/test/screenshots/library_titles_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/library_titles_phone915.png b/android-tv/app/src/test/screenshots/library_titles_phone915.png index 665153d5..8c2e4200 100644 Binary files a/android-tv/app/src/test/screenshots/library_titles_phone915.png and b/android-tv/app/src/test/screenshots/library_titles_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/library_titles_series.png b/android-tv/app/src/test/screenshots/library_titles_series.png index 3f82cc84..b0d44aba 100644 Binary files a/android-tv/app/src/test/screenshots/library_titles_series.png and b/android-tv/app/src/test/screenshots/library_titles_series.png differ diff --git a/android-tv/app/src/test/screenshots/library_titles_tv.png b/android-tv/app/src/test/screenshots/library_titles_tv.png index 32336e13..dc04629f 100644 Binary files a/android-tv/app/src/test/screenshots/library_titles_tv.png and b/android-tv/app/src/test/screenshots/library_titles_tv.png differ diff --git a/android-tv/app/src/test/screenshots/search_empty_language.png b/android-tv/app/src/test/screenshots/search_empty_language.png index f25b045d..f443744a 100644 Binary files a/android-tv/app/src/test/screenshots/search_empty_language.png and b/android-tv/app/src/test/screenshots/search_empty_language.png differ diff --git a/android-tv/app/src/test/screenshots/search_failed.png b/android-tv/app/src/test/screenshots/search_failed.png index caa95288..5c4f3809 100644 Binary files a/android-tv/app/src/test/screenshots/search_failed.png and b/android-tv/app/src/test/screenshots/search_failed.png differ diff --git a/android-tv/app/src/test/screenshots/search_grab_huge.png b/android-tv/app/src/test/screenshots/search_grab_huge.png index c2917969..e2d4969e 100644 Binary files a/android-tv/app/src/test/screenshots/search_grab_huge.png and b/android-tv/app/src/test/screenshots/search_grab_huge.png differ diff --git a/android-tv/app/src/test/screenshots/search_grab_refused.png b/android-tv/app/src/test/screenshots/search_grab_refused.png index 53a20a76..0e176e7c 100644 Binary files a/android-tv/app/src/test/screenshots/search_grab_refused.png and b/android-tv/app/src/test/screenshots/search_grab_refused.png differ diff --git a/android-tv/app/src/test/screenshots/search_grid_phone800.png b/android-tv/app/src/test/screenshots/search_grid_phone800.png index aad761e7..7bcaa67b 100644 Binary files a/android-tv/app/src/test/screenshots/search_grid_phone800.png and b/android-tv/app/src/test/screenshots/search_grid_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/search_grid_phone915.png b/android-tv/app/src/test/screenshots/search_grid_phone915.png index 1bf20319..9e4f64cf 100644 Binary files a/android-tv/app/src/test/screenshots/search_grid_phone915.png and b/android-tv/app/src/test/screenshots/search_grid_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/search_grid_tv.png b/android-tv/app/src/test/screenshots/search_grid_tv.png index fc353aed..fd898692 100644 Binary files a/android-tv/app/src/test/screenshots/search_grid_tv.png and b/android-tv/app/src/test/screenshots/search_grid_tv.png differ diff --git a/android-tv/app/src/test/screenshots/search_list_phone800.png b/android-tv/app/src/test/screenshots/search_list_phone800.png index e7dc3e9f..da518f99 100644 Binary files a/android-tv/app/src/test/screenshots/search_list_phone800.png and b/android-tv/app/src/test/screenshots/search_list_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/search_list_phone915.png b/android-tv/app/src/test/screenshots/search_list_phone915.png index f1457878..f6e97bf2 100644 Binary files a/android-tv/app/src/test/screenshots/search_list_phone915.png and b/android-tv/app/src/test/screenshots/search_list_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/search_list_tv.png b/android-tv/app/src/test/screenshots/search_list_tv.png index c81ea5f6..4897bb38 100644 Binary files a/android-tv/app/src/test/screenshots/search_list_tv.png and b/android-tv/app/src/test/screenshots/search_list_tv.png differ diff --git a/android-tv/app/src/test/screenshots/search_loading.png b/android-tv/app/src/test/screenshots/search_loading.png index d17be327..032afe6e 100644 Binary files a/android-tv/app/src/test/screenshots/search_loading.png and b/android-tv/app/src/test/screenshots/search_loading.png differ diff --git a/android-tv/app/src/test/screenshots/search_no_recent.png b/android-tv/app/src/test/screenshots/search_no_recent.png index aa43ad43..6b780885 100644 Binary files a/android-tv/app/src/test/screenshots/search_no_recent.png and b/android-tv/app/src/test/screenshots/search_no_recent.png differ diff --git a/android-tv/app/src/test/screenshots/search_show_more.png b/android-tv/app/src/test/screenshots/search_show_more.png index 8a2da53f..281ddedf 100644 Binary files a/android-tv/app/src/test/screenshots/search_show_more.png and b/android-tv/app/src/test/screenshots/search_show_more.png differ diff --git a/android-tv/app/src/test/screenshots/search_start_phone800.png b/android-tv/app/src/test/screenshots/search_start_phone800.png index 6719aa0f..d98e4754 100644 Binary files a/android-tv/app/src/test/screenshots/search_start_phone800.png and b/android-tv/app/src/test/screenshots/search_start_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/search_start_phone915.png b/android-tv/app/src/test/screenshots/search_start_phone915.png index 2c5a6cac..645f9256 100644 Binary files a/android-tv/app/src/test/screenshots/search_start_phone915.png and b/android-tv/app/src/test/screenshots/search_start_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/search_start_tv.png b/android-tv/app/src/test/screenshots/search_start_tv.png index 8c6fe505..0f382f58 100644 Binary files a/android-tv/app/src/test/screenshots/search_start_tv.png and b/android-tv/app/src/test/screenshots/search_start_tv.png differ diff --git a/android-tv/app/src/test/screenshots/search_titles_phone800.png b/android-tv/app/src/test/screenshots/search_titles_phone800.png index c8062edb..008a0900 100644 Binary files a/android-tv/app/src/test/screenshots/search_titles_phone800.png and b/android-tv/app/src/test/screenshots/search_titles_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/search_titles_phone915.png b/android-tv/app/src/test/screenshots/search_titles_phone915.png index c7d3d256..fb6e0bd5 100644 Binary files a/android-tv/app/src/test/screenshots/search_titles_phone915.png and b/android-tv/app/src/test/screenshots/search_titles_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/search_titles_tv.png b/android-tv/app/src/test/screenshots/search_titles_tv.png index 864ecd9a..2921ef4b 100644 Binary files a/android-tv/app/src/test/screenshots/search_titles_tv.png and b/android-tv/app/src/test/screenshots/search_titles_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_pair_phone800.png b/android-tv/app/src/test/screenshots/settings_devices_pair_phone800.png index 8a741847..1dcefb9f 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_pair_phone800.png and b/android-tv/app/src/test/screenshots/settings_devices_pair_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_pair_phone915.png b/android-tv/app/src/test/screenshots/settings_devices_pair_phone915.png index 9360b99a..4ff61c34 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_pair_phone915.png and b/android-tv/app/src/test/screenshots/settings_devices_pair_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_pair_tv.png b/android-tv/app/src/test/screenshots/settings_devices_pair_tv.png index 0bc768bb..a55baf9d 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_pair_tv.png and b/android-tv/app/src/test/screenshots/settings_devices_pair_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_phone800.png b/android-tv/app/src/test/screenshots/settings_devices_phone800.png index 37a6db98..9f18b1ea 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_phone800.png and b/android-tv/app/src/test/screenshots/settings_devices_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_phone915.png b/android-tv/app/src/test/screenshots/settings_devices_phone915.png index f6995e05..97c558e9 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_phone915.png and b/android-tv/app/src/test/screenshots/settings_devices_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_revoke.png b/android-tv/app/src/test/screenshots/settings_devices_revoke.png index fbc367c2..f31f51e7 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_revoke.png and b/android-tv/app/src/test/screenshots/settings_devices_revoke.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_tv.png b/android-tv/app/src/test/screenshots/settings_devices_tv.png index 7388acba..9e4286f5 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_tv.png and b/android-tv/app/src/test/screenshots/settings_devices_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_devices_waiting.png b/android-tv/app/src/test/screenshots/settings_devices_waiting.png index ba3bf8d1..f8f9403a 100644 Binary files a/android-tv/app/src/test/screenshots/settings_devices_waiting.png and b/android-tv/app/src/test/screenshots/settings_devices_waiting.png differ diff --git a/android-tv/app/src/test/screenshots/settings_failed.png b/android-tv/app/src/test/screenshots/settings_failed.png index a04390b6..c1c4852a 100644 Binary files a/android-tv/app/src/test/screenshots/settings_failed.png and b/android-tv/app/src/test/screenshots/settings_failed.png differ diff --git a/android-tv/app/src/test/screenshots/settings_friends_phone800.png b/android-tv/app/src/test/screenshots/settings_friends_phone800.png new file mode 100644 index 00000000..f9ef4754 Binary files /dev/null and b/android-tv/app/src/test/screenshots/settings_friends_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_friends_phone915.png b/android-tv/app/src/test/screenshots/settings_friends_phone915.png new file mode 100644 index 00000000..6ec40e48 Binary files /dev/null and b/android-tv/app/src/test/screenshots/settings_friends_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_friends_tv.png b/android-tv/app/src/test/screenshots/settings_friends_tv.png new file mode 100644 index 00000000..f8e389fd Binary files /dev/null and b/android-tv/app/src/test/screenshots/settings_friends_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_loading.png b/android-tv/app/src/test/screenshots/settings_loading.png index f38ad891..4354b876 100644 Binary files a/android-tv/app/src/test/screenshots/settings_loading.png and b/android-tv/app/src/test/screenshots/settings_loading.png differ diff --git a/android-tv/app/src/test/screenshots/settings_passkeys_none.png b/android-tv/app/src/test/screenshots/settings_passkeys_none.png index 28fda185..350d448b 100644 Binary files a/android-tv/app/src/test/screenshots/settings_passkeys_none.png and b/android-tv/app/src/test/screenshots/settings_passkeys_none.png differ diff --git a/android-tv/app/src/test/screenshots/settings_passkeys_phone800.png b/android-tv/app/src/test/screenshots/settings_passkeys_phone800.png index 61d9d052..1febf3e1 100644 Binary files a/android-tv/app/src/test/screenshots/settings_passkeys_phone800.png and b/android-tv/app/src/test/screenshots/settings_passkeys_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_passkeys_phone915.png b/android-tv/app/src/test/screenshots/settings_passkeys_phone915.png index d9dde1d6..41b83a9c 100644 Binary files a/android-tv/app/src/test/screenshots/settings_passkeys_phone915.png and b/android-tv/app/src/test/screenshots/settings_passkeys_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_passkeys_tv.png b/android-tv/app/src/test/screenshots/settings_passkeys_tv.png index ea5fe24f..328b63a4 100644 Binary files a/android-tv/app/src/test/screenshots/settings_passkeys_tv.png and b/android-tv/app/src/test/screenshots/settings_passkeys_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_password.png b/android-tv/app/src/test/screenshots/settings_password.png index e1fdbb87..fea2861e 100644 Binary files a/android-tv/app/src/test/screenshots/settings_password.png and b/android-tv/app/src/test/screenshots/settings_password.png differ diff --git a/android-tv/app/src/test/screenshots/settings_password_dialog_phone800.png b/android-tv/app/src/test/screenshots/settings_password_dialog_phone800.png index afeee62f..520f3faa 100644 Binary files a/android-tv/app/src/test/screenshots/settings_password_dialog_phone800.png and b/android-tv/app/src/test/screenshots/settings_password_dialog_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_password_dialog_phone915.png b/android-tv/app/src/test/screenshots/settings_password_dialog_phone915.png index fc97605a..e622051b 100644 Binary files a/android-tv/app/src/test/screenshots/settings_password_dialog_phone915.png and b/android-tv/app/src/test/screenshots/settings_password_dialog_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_password_dialog_tv.png b/android-tv/app/src/test/screenshots/settings_password_dialog_tv.png index 0de334cf..5e391b44 100644 Binary files a/android-tv/app/src/test/screenshots/settings_password_dialog_tv.png and b/android-tv/app/src/test/screenshots/settings_password_dialog_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_playback_audio_phone800.png b/android-tv/app/src/test/screenshots/settings_playback_audio_phone800.png index bfa39d7c..5d9924f6 100644 Binary files a/android-tv/app/src/test/screenshots/settings_playback_audio_phone800.png and b/android-tv/app/src/test/screenshots/settings_playback_audio_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_playback_audio_phone915.png b/android-tv/app/src/test/screenshots/settings_playback_audio_phone915.png index 6fffea2e..d93aea93 100644 Binary files a/android-tv/app/src/test/screenshots/settings_playback_audio_phone915.png and b/android-tv/app/src/test/screenshots/settings_playback_audio_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_playback_audio_tv.png b/android-tv/app/src/test/screenshots/settings_playback_audio_tv.png index d4a0054e..b43d9b7d 100644 Binary files a/android-tv/app/src/test/screenshots/settings_playback_audio_tv.png and b/android-tv/app/src/test/screenshots/settings_playback_audio_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_playback_phone800.png b/android-tv/app/src/test/screenshots/settings_playback_phone800.png index 6e613bc6..cd2d5930 100644 Binary files a/android-tv/app/src/test/screenshots/settings_playback_phone800.png and b/android-tv/app/src/test/screenshots/settings_playback_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_playback_phone915.png b/android-tv/app/src/test/screenshots/settings_playback_phone915.png index 00712ca0..b5170da6 100644 Binary files a/android-tv/app/src/test/screenshots/settings_playback_phone915.png and b/android-tv/app/src/test/screenshots/settings_playback_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_playback_tv.png b/android-tv/app/src/test/screenshots/settings_playback_tv.png index 1a16fabc..5a9e1522 100644 Binary files a/android-tv/app/src/test/screenshots/settings_playback_tv.png and b/android-tv/app/src/test/screenshots/settings_playback_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_profiles_phone800.png b/android-tv/app/src/test/screenshots/settings_profiles_phone800.png new file mode 100644 index 00000000..dba9d503 Binary files /dev/null and b/android-tv/app/src/test/screenshots/settings_profiles_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_profiles_phone915.png b/android-tv/app/src/test/screenshots/settings_profiles_phone915.png new file mode 100644 index 00000000..8d092cf6 Binary files /dev/null and b/android-tv/app/src/test/screenshots/settings_profiles_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_profiles_tv.png b/android-tv/app/src/test/screenshots/settings_profiles_tv.png new file mode 100644 index 00000000..7e611069 Binary files /dev/null and b/android-tv/app/src/test/screenshots/settings_profiles_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_recommendations_phone800.png b/android-tv/app/src/test/screenshots/settings_recommendations_phone800.png index 8db187eb..c9f907bf 100644 Binary files a/android-tv/app/src/test/screenshots/settings_recommendations_phone800.png and b/android-tv/app/src/test/screenshots/settings_recommendations_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_recommendations_phone915.png b/android-tv/app/src/test/screenshots/settings_recommendations_phone915.png index 960a5f2b..4d2fec29 100644 Binary files a/android-tv/app/src/test/screenshots/settings_recommendations_phone915.png and b/android-tv/app/src/test/screenshots/settings_recommendations_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_recommendations_tv.png b/android-tv/app/src/test/screenshots/settings_recommendations_tv.png index 020310ef..af8a9fc9 100644 Binary files a/android-tv/app/src/test/screenshots/settings_recommendations_tv.png and b/android-tv/app/src/test/screenshots/settings_recommendations_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_this_tv_largest_tv.png b/android-tv/app/src/test/screenshots/settings_this_tv_largest_tv.png index 80aa4934..5d80cdb6 100644 Binary files a/android-tv/app/src/test/screenshots/settings_this_tv_largest_tv.png and b/android-tv/app/src/test/screenshots/settings_this_tv_largest_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_this_tv_phone915.png b/android-tv/app/src/test/screenshots/settings_this_tv_phone915.png index bd4d2fe0..5befb416 100644 Binary files a/android-tv/app/src/test/screenshots/settings_this_tv_phone915.png and b/android-tv/app/src/test/screenshots/settings_this_tv_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_this_tv_server.png b/android-tv/app/src/test/screenshots/settings_this_tv_server.png index 592432a9..adad12cf 100644 Binary files a/android-tv/app/src/test/screenshots/settings_this_tv_server.png and b/android-tv/app/src/test/screenshots/settings_this_tv_server.png differ diff --git a/android-tv/app/src/test/screenshots/settings_this_tv_tv.png b/android-tv/app/src/test/screenshots/settings_this_tv_tv.png index 4febf712..fc252c40 100644 Binary files a/android-tv/app/src/test/screenshots/settings_this_tv_tv.png and b/android-tv/app/src/test/screenshots/settings_this_tv_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_checking.png b/android-tv/app/src/test/screenshots/settings_update_checking.png index a405d26f..b047cbb2 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_checking.png and b/android-tv/app/src/test/screenshots/settings_update_checking.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_current.png b/android-tv/app/src/test/screenshots/settings_update_current.png index df40d22d..aa7569f3 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_current.png and b/android-tv/app/src/test/screenshots/settings_update_current.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_downloading.png b/android-tv/app/src/test/screenshots/settings_update_downloading.png index 7072f442..f642a106 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_downloading.png and b/android-tv/app/src/test/screenshots/settings_update_downloading.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_failed.png b/android-tv/app/src/test/screenshots/settings_update_failed.png index a848df6e..5e40be85 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_failed.png and b/android-tv/app/src/test/screenshots/settings_update_failed.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_phone800.png b/android-tv/app/src/test/screenshots/settings_update_phone800.png index 2cfafc66..8935694c 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_phone800.png and b/android-tv/app/src/test/screenshots/settings_update_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_phone915.png b/android-tv/app/src/test/screenshots/settings_update_phone915.png index 595047db..01432a05 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_phone915.png and b/android-tv/app/src/test/screenshots/settings_update_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_ready.png b/android-tv/app/src/test/screenshots/settings_update_ready.png index 3ed8d8f8..ec0df0bf 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_ready.png and b/android-tv/app/src/test/screenshots/settings_update_ready.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_tv.png b/android-tv/app/src/test/screenshots/settings_update_tv.png index 9356ef39..61a073ca 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_tv.png and b/android-tv/app/src/test/screenshots/settings_update_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_update_unknown.png b/android-tv/app/src/test/screenshots/settings_update_unknown.png index 0ad03e7e..02d7aad4 100644 Binary files a/android-tv/app/src/test/screenshots/settings_update_unknown.png and b/android-tv/app/src/test/screenshots/settings_update_unknown.png differ diff --git a/android-tv/app/src/test/screenshots/settings_you_phone800.png b/android-tv/app/src/test/screenshots/settings_you_phone800.png index 2258efbf..173cdac0 100644 Binary files a/android-tv/app/src/test/screenshots/settings_you_phone800.png and b/android-tv/app/src/test/screenshots/settings_you_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/settings_you_phone915.png b/android-tv/app/src/test/screenshots/settings_you_phone915.png index 51ee1fd4..c62ed737 100644 Binary files a/android-tv/app/src/test/screenshots/settings_you_phone915.png and b/android-tv/app/src/test/screenshots/settings_you_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/settings_you_renamed.png b/android-tv/app/src/test/screenshots/settings_you_renamed.png index 336418dd..733a3a87 100644 Binary files a/android-tv/app/src/test/screenshots/settings_you_renamed.png and b/android-tv/app/src/test/screenshots/settings_you_renamed.png differ diff --git a/android-tv/app/src/test/screenshots/settings_you_tv.png b/android-tv/app/src/test/screenshots/settings_you_tv.png index 05e56476..15ff8bb1 100644 Binary files a/android-tv/app/src/test/screenshots/settings_you_tv.png and b/android-tv/app/src/test/screenshots/settings_you_tv.png differ diff --git a/android-tv/app/src/test/screenshots/shell_header_over_hero.png b/android-tv/app/src/test/screenshots/shell_header_over_hero.png new file mode 100644 index 00000000..23c830a3 Binary files /dev/null and b/android-tv/app/src/test/screenshots/shell_header_over_hero.png differ diff --git a/android-tv/app/src/test/screenshots/shell_header_over_light.png b/android-tv/app/src/test/screenshots/shell_header_over_light.png new file mode 100644 index 00000000..82bc016d Binary files /dev/null and b/android-tv/app/src/test/screenshots/shell_header_over_light.png differ diff --git a/android-tv/app/src/test/screenshots/shell_header_over_light_before.png b/android-tv/app/src/test/screenshots/shell_header_over_light_before.png new file mode 100644 index 00000000..f687112b Binary files /dev/null and b/android-tv/app/src/test/screenshots/shell_header_over_light_before.png differ diff --git a/android-tv/app/src/test/screenshots/shell_header_over_mid.png b/android-tv/app/src/test/screenshots/shell_header_over_mid.png new file mode 100644 index 00000000..39066561 Binary files /dev/null and b/android-tv/app/src/test/screenshots/shell_header_over_mid.png differ diff --git a/android-tv/app/src/test/screenshots/shell_header_update_phone800.png b/android-tv/app/src/test/screenshots/shell_header_update_phone800.png index 184e9d71..f497e198 100644 Binary files a/android-tv/app/src/test/screenshots/shell_header_update_phone800.png and b/android-tv/app/src/test/screenshots/shell_header_update_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/shell_header_update_phone915.png b/android-tv/app/src/test/screenshots/shell_header_update_phone915.png index 064fecb2..acda0cd6 100644 Binary files a/android-tv/app/src/test/screenshots/shell_header_update_phone915.png and b/android-tv/app/src/test/screenshots/shell_header_update_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/shell_header_update_tv.png b/android-tv/app/src/test/screenshots/shell_header_update_tv.png index c3f61c7f..ee7350aa 100644 Binary files a/android-tv/app/src/test/screenshots/shell_header_update_tv.png and b/android-tv/app/src/test/screenshots/shell_header_update_tv.png differ diff --git a/android-tv/app/src/test/screenshots/shell_library_phone800.png b/android-tv/app/src/test/screenshots/shell_library_phone800.png index 461446c9..8529c85a 100644 Binary files a/android-tv/app/src/test/screenshots/shell_library_phone800.png and b/android-tv/app/src/test/screenshots/shell_library_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/shell_library_phone915.png b/android-tv/app/src/test/screenshots/shell_library_phone915.png index 60b5f2d0..7ccfd1ce 100644 Binary files a/android-tv/app/src/test/screenshots/shell_library_phone915.png and b/android-tv/app/src/test/screenshots/shell_library_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/shell_library_tv.png b/android-tv/app/src/test/screenshots/shell_library_tv.png index 1dd24521..a9c69457 100644 Binary files a/android-tv/app/src/test/screenshots/shell_library_tv.png and b/android-tv/app/src/test/screenshots/shell_library_tv.png differ diff --git a/crates/iris-api/src/app.rs b/crates/iris-api/src/app.rs index b904468a..8f0fa228 100644 --- a/crates/iris-api/src/app.rs +++ b/crates/iris-api/src/app.rs @@ -19,11 +19,11 @@ use crate::state::AppState; pub fn build_router(state: AppState) -> Router { // TWO rate-limit lanes on `/api/auth/*`, keyed per client IP - // (`CF-Connecting-IP`; peer socket IP only for non-tunnelled/dev access — + // (`CF-Connecting-IP`; peer socket IP only for direct/dev access — // see `rate_limit::CloudflareIpKeyExtractor`). // // NOTE on the key: every device in a household reaches Iris through the - // same Cloudflare URL, so `CF-Connecting-IP` is the household's single + // same public URL, so `CF-Connecting-IP` is the household's single // public (NAT) IP — ONE bucket shared by every browser, phone and TV in // the home. That makes the split below the real lever, not the key: // @@ -63,6 +63,7 @@ pub fn build_router(state: AppState) -> Router { .nest("/devices", routes::devices::me_router()) .nest("/passkeys", routes::passkeys::me_router()) .nest("/follows", routes::follows::router()) + .nest("/friends", routes::friends::router()) .nest("/preferences", routes::preferences::router()) .nest( "/playback-preferences", @@ -97,6 +98,7 @@ pub fn build_router(state: AppState) -> Router { .nest("/torrents", torrents) .nest("/providers", routes::providers::router()) .nest("/metadata", routes::metadata::router()) + .route("/release-note", get(routes::release_note::get)) .layer(axum::middleware::from_fn(client_version_layer)); let api = Router::new() .route("/health", get(routes::health::get)) diff --git a/crates/iris-api/src/openapi.rs b/crates/iris-api/src/openapi.rs index f491f5bd..3e08b3e3 100644 --- a/crates/iris-api/src/openapi.rs +++ b/crates/iris-api/src/openapi.rs @@ -153,11 +153,39 @@ use utoipa::OpenApi; crate::routes::seleo::share, crate::routes::seleo::unshare, crate::routes::health::get, + crate::routes::release_note::get, + crate::routes::friends::overview, + crate::routes::friends::search, + crate::routes::friends::ask, + crate::routes::friends::accept, + crate::routes::friends::decline, + crate::routes::friends::remove, + crate::routes::friends::set_sharing, + crate::routes::friends::hide, + crate::routes::friends::unhide, + crate::routes::friends::activity, + crate::routes::profiles::list, + crate::routes::profiles::create, + crate::routes::profiles::remove, + crate::routes::profiles::switch, ), components(schemas( crate::routes::auth::RegisterRequest, crate::routes::auth::LoginRequest, crate::routes::auth::UserResponse, + crate::routes::release_note::ReleaseNote, + crate::routes::friends::PersonView, + crate::routes::friends::HiddenTitleView, + crate::routes::friends::FriendsOverview, + crate::routes::friends::SharingRequest, + crate::routes::friends::HideRequest, + crate::routes::friends::UnhideRequest, + crate::routes::friends::FriendAction, + crate::routes::friends::FriendEvent, + crate::routes::friends::FriendActivity, + crate::routes::profiles::ProfileView, + crate::routes::profiles::CreateProfileRequest, + crate::routes::profiles::SwitchProfileRequest, crate::routes::devices::CreateCodeRequest, crate::routes::devices::CreateCodeResponse, crate::routes::devices::LinkRequest, @@ -224,6 +252,7 @@ use utoipa::OpenApi; (name = "providers", description = "Registered search providers and their capabilities."), (name = "metadata", description = "TMDB metadata lookup, typeahead search, SCENE-name resolution."), (name = "admin", description = "Admin-only: invitations, users, storage / GC, remux cache, presence, diagnostics, SéléoTV shares."), + (name = "friends", description = "Friends between profiles, and what they share."), (name = "health", description = "Unauthenticated liveness probe (excluded from the client-version gate)."), ), )] diff --git a/crates/iris-api/src/rate_limit.rs b/crates/iris-api/src/rate_limit.rs index 782cdbeb..c61abedb 100644 --- a/crates/iris-api/src/rate_limit.rs +++ b/crates/iris-api/src/rate_limit.rs @@ -1,26 +1,24 @@ -//! Per-IP rate limiting for the auth surface, behind a Cloudflare tunnel. +//! Per-IP rate limiting for the auth surface, behind a reverse proxy. //! -//! Wraps [`tower_governor`] with a Cloudflare-aware [`KeyExtractor`]: -//! the tunnel terminates locally (peer IP = loopback for every request), -//! so we cannot use `PeerIpKeyExtractor` / `SmartIpKeyExtractor`. Instead -//! we trust the `CF-Connecting-IP` header — Cloudflare sets it to the -//! real client IP on every request, and because the only path into the -//! origin is through the tunnel, an attacker cannot spoof it without -//! first bypassing Cloudflare. +//! Wraps [`tower_governor`] with a [`KeyExtractor`] that reads the proxy's +//! word for the client: the proxy runs on the host (peer IP = the same local +//! address for every request), so we cannot use `PeerIpKeyExtractor` / +//! `SmartIpKeyExtractor`. Instead we trust the `CF-Connecting-IP` header. The +//! proxy must set it to the client's address on every request, replacing +//! whatever the client sent (`docs/DEPLOYMENT.md` §4), and because the HTTP +//! port is published on loopback only, an attacker cannot forge it without +//! first getting past the proxy. //! -//! Normal traffic — including a LAN Android TV reaching Iris through the -//! Cloudflare URL — always carries `CF-Connecting-IP`, set to the client's -//! public IP. A household sits behind one NAT, so that is a single shared key -//! for every device in the home; the per-lane bucket sizing in `app.rs` -//! accounts for that aggregate. +//! Normal traffic, including a LAN Android TV reaching Iris through the public +//! URL, always carries the header, set to the client's public IP. A household +//! sits behind one NAT, so that is a single shared key for every device in the +//! home; the per-lane bucket sizing in `app.rs` accounts for that aggregate. //! -//! Requests arriving WITHOUT `CF-Connecting-IP` only happen on direct origin -//! access (dev, or something bypassing the tunnel). Those are keyed on the -//! real peer socket IP so each direct caller gets its own bucket instead of -//! all of them collapsing onto one shared key. A tunnelled header-strip -//! attempt still cannot earn fresh quota: its socket IP is the tunnel's -//! loopback, so all such requests share the loopback bucket. Connect-info is -//! absent only if the server is served without +//! Requests arriving WITHOUT the header only happen on direct origin access +//! (dev), or behind a proxy that does not set it. They are keyed on the real +//! peer socket IP, so each direct caller gets its own bucket; behind such a +//! proxy every client shares the proxy's bucket, which throttles rather than +//! opens. Connect-info is absent only if the server is served without //! `into_make_service_with_connect_info`; we fall back to loopback then. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -109,7 +107,7 @@ impl KeyExtractor for CloudflareIpKeyExtractor { } /// The client a request comes from, as the module docs describe: the -/// `CF-Connecting-IP` set by the tunnel, else the peer socket, else loopback; +/// `CF-Connecting-IP` set by the proxy, else the peer socket, else loopback; /// folded by [`address_key`]. pub fn client_ip(headers: &http::HeaderMap, extensions: &http::Extensions) -> IpAddr { address_key(raw_client_ip(headers, extensions)) @@ -132,10 +130,9 @@ fn raw_client_ip(headers: &http::HeaderMap, extensions: &http::Extensions) -> Ip { return ip; } - // No CF header => the request didn't come through the tunnel. Key on - // the real peer socket IP so each LAN device gets its own bucket - // (see module docs). Tunnelled traffic always carries the CF header - // and is handled above; everything reaching here is a direct peer. + // No header => the request didn't come through a proxy that sets it. Key + // on the real peer socket IP so each direct caller gets its own bucket + // (see module docs). if let Some(ConnectInfo(addr)) = extensions.get::>() { return addr.ip(); } diff --git a/crates/iris-api/src/routes/admin.rs b/crates/iris-api/src/routes/admin.rs index 2ce1dcdf..769ecc39 100644 --- a/crates/iris-api/src/routes/admin.rs +++ b/crates/iris-api/src/routes/admin.rs @@ -526,7 +526,7 @@ pub(crate) async fn delete_user( if !iris_db::users::delete(state.db(), user_id, admin.0.id).await? { return Err(ApiError::NotFound); } - state.session_cuts().forget(user_id); + state.session_cuts().forget(); super::audit( &state, admin.0.id, @@ -573,7 +573,7 @@ pub(crate) async fn reset_user_password( let passkeys = iris_db::users::set_password(state.db(), user_id, &hash) .await? .ok_or(ApiError::NotFound)?; - state.session_cuts().forget(user_id); + state.session_cuts().forget(); super::audit( &state, admin.0.id, diff --git a/crates/iris-api/src/routes/auth.rs b/crates/iris-api/src/routes/auth.rs index d6a5d4ad..15c3e829 100644 --- a/crates/iris-api/src/routes/auth.rs +++ b/crates/iris-api/src/routes/auth.rs @@ -49,6 +49,7 @@ pub fn session_router() -> Router { Router::new() .route("/refresh", post(refresh)) .route("/logout", post(logout)) + .route("/switch-profile", post(crate::routes::profiles::switch)) } #[derive(Debug, Deserialize, ToSchema)] @@ -64,6 +65,8 @@ pub struct UserResponse { pub email: String, pub display_name: String, pub is_admin: bool, + /// The account this profile hangs from; absent on the account itself. + pub account_id: Option, } impl From for UserResponse { @@ -73,6 +76,7 @@ impl From for UserResponse { email: user.email, display_name: user.display_name, is_admin: user.is_admin, + account_id: user.account_id.map(Uuid::from), } } } @@ -406,7 +410,7 @@ fn build_cookie( .http_only(true) .same_site(SameSite::Lax) // `Secure` derived from the public URL scheme (config: auth.cookie_secure): - // on behind the TLS tunnel, off for http://localhost dev so login works. + // on behind the TLS proxy, off for http://localhost dev so login works. .secure(secure) .path(path) // Max-Age, not an absolute Expires: a client whose clock runs behind diff --git a/crates/iris-api/src/routes/friends.rs b/crates/iris-api/src/routes/friends.rs new file mode 100644 index 00000000..623fd26f --- /dev/null +++ b/crates/iris-api/src/routes/friends.rs @@ -0,0 +1,534 @@ +//! Friends: asking, answering, and the activity friends see. What a profile +//! adds is visible to its friends; what it watches only once it turned +//! sharing on, and never a title it hid. + +use std::collections::HashSet; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post, put}; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use iris_core::ids::UserId; +use iris_db::friends::{Event, Title}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::routes::extract::AuthUser; +use crate::state::AppState; + +/// Under `/me/friends`. +pub fn router() -> Router { + Router::new() + .route("/", get(overview)) + .route("/search", get(search)) + .route("/activity", get(activity)) + .route("/sharing", put(set_sharing)) + .route("/hidden", post(hide).delete(unhide)) + .route("/{id}", delete(remove).post(ask)) + .route("/{id}/accept", post(accept)) + .route("/{id}/decline", post(decline)) +} + +const RECENT: i64 = 30; +const FOUND: i64 = 20; + +#[derive(Debug, Serialize, ToSchema)] +pub struct PersonView { + pub id: Uuid, + pub name: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct HiddenTitleView { + pub collection_id: Option, + pub infohash: Option, + pub title: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FriendsOverview { + pub friends: Vec, + /// Requests waiting for this profile's answer. + pub incoming: Vec, + /// Requests this profile sent, still unanswered as far as it can tell. + pub outgoing: Vec, + /// Whether friends see what this profile watches. + pub share_watching: bool, + pub hidden: Vec, +} + +#[utoipa::path( + get, + path = "/api/me/friends", + operation_id = "friends_overview", + responses((status = 200, description = "Friends, requests and what is shared", body = FriendsOverview)), + tag = "friends", +)] +pub(crate) async fn overview( + State(state): State, + user: AuthUser, +) -> ApiResult> { + let mut view = FriendsOverview { + friends: Vec::new(), + incoming: Vec::new(), + outgoing: Vec::new(), + share_watching: iris_db::friends::sharing(state.db(), user.id).await?, + hidden: iris_db::friends::hidden(state.db(), user.id) + .await? + .into_iter() + .map(|h| HiddenTitleView { + collection_id: h.collection_id, + infohash: h.infohash, + title: h.title, + }) + .collect(), + }; + for r in iris_db::friends::relations(state.db(), user.id).await? { + let list = match (r.accepted, r.incoming) { + (true, _) => &mut view.friends, + (false, true) => &mut view.incoming, + (false, false) => &mut view.outgoing, + }; + list.push(PersonView { + id: r.id, + name: r.name, + }); + } + Ok(Json(view)) +} + +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub(crate) struct SearchParams { + /// Part of a display name, two characters at least. + q: String, +} + +#[utoipa::path( + get, + path = "/api/me/friends/search", + operation_id = "friends_search", + params(SearchParams), + responses((status = 200, description = "Profiles to ask, by name; never an email", body = [PersonView])), + tag = "friends", +)] +pub(crate) async fn search( + State(state): State, + user: AuthUser, + Query(params): Query, +) -> ApiResult>> { + let q = params.q.trim(); + if q.chars().count() < 2 { + return Ok(Json(Vec::new())); + } + let found = iris_db::friends::search(state.db(), user.id, q, FOUND).await?; + Ok(Json( + found + .into_iter() + .map(|p| PersonView { + id: p.id, + name: p.name, + }) + .collect(), + )) +} + +#[utoipa::path( + post, + path = "/api/me/friends/{id}", + operation_id = "friends_ask", + params(("id" = Uuid, Path, description = "The profile to ask")), + responses( + (status = 204, description = "Asked; when they had asked first, you are friends"), + (status = 404, description = "No such profile"), + ), + tag = "friends", +)] +pub(crate) async fn ask( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + let other = UserId::from(id); + if other == user.id { + return Err(ApiError::BadRequest("that is you".into())); + } + if iris_db::users::find_by_id(state.db(), other) + .await? + .is_none() + { + return Err(ApiError::NotFound); + } + iris_db::friends::request(state.db(), user.id, other).await?; + Ok(StatusCode::NO_CONTENT) +} + +fn done(found: bool) -> ApiResult { + if found { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::NotFound) + } +} + +#[utoipa::path( + post, + path = "/api/me/friends/{id}/accept", + operation_id = "friends_accept", + params(("id" = Uuid, Path, description = "Who asked")), + responses( + (status = 204, description = "You are friends"), + (status = 404, description = "No such request"), + ), + tag = "friends", +)] +pub(crate) async fn accept( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + done(iris_db::friends::accept(state.db(), user.id, UserId::from(id)).await?) +} + +#[utoipa::path( + post, + path = "/api/me/friends/{id}/decline", + operation_id = "friends_decline", + params(("id" = Uuid, Path, description = "Who asked")), + responses( + (status = 204, description = "Declined; they are not told"), + (status = 404, description = "No such request"), + ), + tag = "friends", +)] +pub(crate) async fn decline( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + done(iris_db::friends::decline(state.db(), user.id, UserId::from(id)).await?) +} + +#[utoipa::path( + delete, + path = "/api/me/friends/{id}", + operation_id = "friends_remove", + params(("id" = Uuid, Path, description = "The friend, or who was asked")), + responses( + (status = 204, description = "Friendship ended or request taken back"), + (status = 404, description = "Nothing to remove"), + ), + tag = "friends", +)] +pub(crate) async fn remove( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + done(iris_db::friends::remove(state.db(), user.id, UserId::from(id)).await?) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SharingRequest { + pub share_watching: bool, +} + +#[utoipa::path( + put, + path = "/api/me/friends/sharing", + operation_id = "friends_set_sharing", + request_body = SharingRequest, + responses((status = 204, description = "Saved")), + tag = "friends", +)] +pub(crate) async fn set_sharing( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult { + iris_db::friends::set_sharing(state.db(), user.id, body.share_watching).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct HideRequest { + pub infohash: String, +} + +#[utoipa::path( + post, + path = "/api/me/friends/hidden", + operation_id = "friends_hide", + request_body = HideRequest, + responses( + (status = 204, description = "The release's whole title is kept from friends"), + (status = 404, description = "No such release"), + ), + tag = "friends", +)] +pub(crate) async fn hide( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult { + done(iris_db::friends::hide(state.db(), user.id, &body.infohash).await?) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UnhideRequest { + pub collection_id: Option, + pub infohash: Option, +} + +#[utoipa::path( + delete, + path = "/api/me/friends/hidden", + operation_id = "friends_unhide", + request_body = UnhideRequest, + responses((status = 204, description = "Friends can see it again")), + tag = "friends", +)] +pub(crate) async fn unhide( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult { + iris_db::friends::unhide( + state.db(), + user.id, + body.collection_id, + body.infohash.as_deref(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FriendAction { + Watching, + Watched, + Added, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FriendEvent { + pub friend_id: Uuid, + pub friend_name: String, + pub action: FriendAction, + pub title: String, + pub collection_id: Option, + pub season: Option, + pub episode: Option, + pub at: DateTime, +} + +impl FriendEvent { + fn of( + friend_id: Uuid, + friend_name: String, + action: FriendAction, + at: DateTime, + t: Title, + ) -> Self { + Self { + friend_id, + friend_name, + action, + title: t.title, + collection_id: t.collection_id, + season: t.season, + episode: t.episode, + at, + } + } + + /// One line per friend and title: an episode or release more of the same + /// title says nothing new. + fn key(&self) -> (Uuid, String) { + let title = self + .collection_id + .map_or_else(|| self.title.clone(), |c| c.to_string()); + (self.friend_id, title) + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FriendActivity { + /// What friends are playing this minute. + pub live: Vec, + /// What they watched and added lately, newest first. + pub recent: Vec, +} + +#[utoipa::path( + get, + path = "/api/me/friends/activity", + operation_id = "friends_activity", + responses((status = 200, description = "What friends watch and add", body = FriendActivity)), + tag = "friends", +)] +pub(crate) async fn activity( + State(state): State, + user: AuthUser, +) -> ApiResult> { + let sharing = iris_db::friends::sharing_friends(state.db(), user.id).await?; + let sessions = state.presence().snapshot().await; + let mut live = Vec::new(); + for friend in sharing { + let Some(session) = sessions.iter().find(|s| s.user_id == friend.id) else { + continue; + }; + let shown = iris_db::friends::shown_title( + state.db(), + friend.id, + &session.infohash, + session.file_idx, + ) + .await?; + if let Some(title) = shown { + live.push(FriendEvent::of( + friend.id, + friend.name, + FriendAction::Watching, + session.last_seen_at, + title, + )); + } + } + + let event = + |action| move |e: Event| FriendEvent::of(e.friend_id, e.friend_name, action, e.at, e.title); + let mut recent: Vec<_> = iris_db::friends::recently_watched(state.db(), user.id, RECENT) + .await? + .into_iter() + .map(event(FriendAction::Watched)) + .chain( + iris_db::friends::recently_added(state.db(), user.id, RECENT) + .await? + .into_iter() + .map(event(FriendAction::Added)), + ) + .collect(); + recent.sort_by_key(|e| std::cmp::Reverse(e.at)); + let mut seen: HashSet<_> = live.iter().map(FriendEvent::key).collect(); + recent.retain(|e| seen.insert(e.key())); + recent.truncate(usize::try_from(RECENT).unwrap_or(usize::MAX)); + Ok(Json(FriendActivity { live, recent })) +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + + use crate::presence::{Heartbeat, PlaybackState}; + use crate::routes::auth::tests::{app_with_member, call}; + + #[tokio::test] + async fn live_watching_needs_a_friend_who_shares_and_never_shows_an_email() { + let (state, app, me, _) = app_with_member().await; + let friend = iris_db::test_support::make_named_user(state.db(), "Muriel").await; + sqlx::query( + "INSERT INTO torrents (id, infohash, name, total_size_bytes, added_by, added_at) \ + VALUES (?1, 'aaaa', 'Dune.2021', 1, ?2, ?3)", + ) + .bind(uuid::Uuid::new_v4()) + .bind(uuid::Uuid::from(friend)) + .bind(chrono::Utc::now()) + .execute(state.db()) + .await + .unwrap(); + state + .presence() + .touch(Heartbeat { + user_id: friend.into(), + infohash: "aaaa".into(), + file_idx: 0, + position_seconds: 1.0, + duration_seconds: None, + state: PlaybackState::Playing, + client: None, + client_version: None, + browser: None, + }) + .await; + let mine = state.jwt().issue_access(me, false).unwrap(); + let theirs = state.jwt().issue_access(friend, false).unwrap(); + let read = async || { + call( + &app, + "GET", + "/api/me/friends/activity", + Some(&mine), + None, + None, + ) + .await + }; + + let found = call( + &app, + "GET", + "/api/me/friends/search?q=mur", + Some(&mine), + None, + None, + ) + .await; + assert_eq!(found.json[0]["name"], "Muriel"); + assert!(found.json[0].get("email").is_none()); + + let path = format!("/api/me/friends/{}", uuid::Uuid::from(friend)); + assert_eq!( + call(&app, "POST", &path, Some(&mine), None, None) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!(read().await.json["recent"], serde_json::json!([])); + + let accepted = format!("/api/me/friends/{}/accept", uuid::Uuid::from(me)); + assert_eq!( + call(&app, "POST", &accepted, Some(&theirs), None, None) + .await + .status, + StatusCode::NO_CONTENT + ); + let friends = read().await; + assert_eq!(friends.json["live"], serde_json::json!([])); + assert_eq!(friends.json["recent"][0]["action"], "added"); + + call( + &app, + "PUT", + "/api/me/friends/sharing", + Some(&theirs), + None, + Some(serde_json::json!({ "share_watching": true })), + ) + .await; + let live = read().await; + assert_eq!(live.json["live"][0]["action"], "watching"); + assert_eq!(live.json["live"][0]["friend_name"], "Muriel"); + } + + #[tokio::test] + async fn only_the_one_asked_can_answer() { + let (state, app, me, _) = app_with_member().await; + let other = iris_db::test_support::make_user(state.db()).await; + let mine = state.jwt().issue_access(me, false).unwrap(); + iris_db::friends::request(state.db(), me, other) + .await + .unwrap(); + let path = format!("/api/me/friends/{}/accept", uuid::Uuid::from(other)); + assert_eq!( + call(&app, "POST", &path, Some(&mine), None, None) + .await + .status, + StatusCode::NOT_FOUND + ); + } +} diff --git a/crates/iris-api/src/routes/me.rs b/crates/iris-api/src/routes/me.rs index 512bd831..438f6604 100644 --- a/crates/iris-api/src/routes/me.rs +++ b/crates/iris-api/src/routes/me.rs @@ -17,6 +17,7 @@ use crate::state::AppState; pub fn router() -> Router { Router::new() .route("/", get(me)) + .nest("/profiles", crate::routes::profiles::router()) .route("/continue-watching", get(continue_watching)) .route( "/continue-watching/dismiss", @@ -65,6 +66,7 @@ pub(crate) async fn change_password( user: AuthUser, Json(body): Json, ) -> ApiResult<(axum_extra::extract::CookieJar, axum::http::StatusCode)> { + crate::routes::profiles::the_account(&state, &user).await?; crate::passwords::check_policy(&body.new_password)?; let current = iris_db::users::get_password_hash(state.db(), user.id) .await? @@ -83,7 +85,7 @@ pub(crate) async fn change_password( let passkeys = iris_db::users::set_password(state.db(), user.id, &new_hash) .await? .ok_or(ApiError::Unauthorized)?; - state.session_cuts().forget(user.id); + state.session_cuts().forget(); let id = Uuid::from(user.id).to_string(); super::audit( &state, @@ -466,6 +468,8 @@ pub(crate) struct HomeSummary { /// Deprecated: always `0` (the watchlist cards count their own). #[schema(deprecated)] new_episodes: i64, + /// Friend requests waiting for this profile's answer. + friend_requests: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -482,8 +486,9 @@ pub(crate) struct DiskSpace { )] pub(crate) async fn summary( State(state): State, - _user: AuthUser, + user: AuthUser, ) -> ApiResult> { + let friend_requests = iris_db::friends::incoming_count(state.db(), user.id).await?; let torrents = state.engine().list(); let active: Vec<_> = torrents .iter() @@ -506,6 +511,7 @@ pub(crate) async fn summary( downloading_eta_seconds, seeding: 0, new_episodes: 0, + friend_requests: Some(friend_requests), })) } diff --git a/crates/iris-api/src/routes/mod.rs b/crates/iris-api/src/routes/mod.rs index 245cbffb..b891b4a0 100644 --- a/crates/iris-api/src/routes/mod.rs +++ b/crates/iris-api/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod discover; pub mod extract; pub mod follows; pub mod foryou; +pub mod friends; pub mod health; pub mod library; pub mod live_tv; @@ -14,7 +15,9 @@ pub mod moods; pub mod passkeys; pub mod playback_preferences; pub mod preferences; +pub mod profiles; pub mod providers; +pub mod release_note; pub mod search; pub mod seleo; pub mod torrents; diff --git a/crates/iris-api/src/routes/passkeys.rs b/crates/iris-api/src/routes/passkeys.rs index 3603ebe5..9bb35289 100644 --- a/crates/iris-api/src/routes/passkeys.rs +++ b/crates/iris-api/src/routes/passkeys.rs @@ -172,6 +172,7 @@ pub(crate) async fn register_start( ClientIp(ip): ClientIp, user: AuthUser, ) -> ApiResult> { + crate::routes::profiles::the_account(&state, &user).await?; let me = iris_db::users::find_by_id(state.db(), user.id) .await? .ok_or(ApiError::Unauthorized)?; diff --git a/crates/iris-api/src/routes/profiles.rs b/crates/iris-api/src/routes/profiles.rs new file mode 100644 index 00000000..9cbdbfd5 --- /dev/null +++ b/crates/iris-api/src/routes/profiles.rs @@ -0,0 +1,343 @@ +//! Profiles of one account ("Vincent", "Muriel"): each is a `users` row of its +//! own, so everything personal is already theirs. Changing profile is a new +//! session on the other row; the account's credentials stay on the account. + +use axum::extract::{Path, State}; +use axum::routing::{delete, get}; +use axum::{Json, Router}; +use axum_extra::extract::CookieJar; +use iris_core::ids::UserId; +use iris_core::user::User; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::routes::auth::UserResponse; +use crate::routes::extract::{AuthUser, REFRESH_COOKIE}; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/", get(list).post(create)) + .route("/{id}", delete(remove)) +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProfileView { + pub id: Uuid, + pub name: String, + /// The account itself: it cannot be removed. + pub main: bool, + /// The profile this session is on. + pub current: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateProfileRequest { + pub name: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SwitchProfileRequest { + pub profile_id: Uuid, +} + +async fn current(state: &AppState, user: &AuthUser) -> ApiResult { + iris_db::users::find_by_id(state.db(), user.id) + .await? + .ok_or(ApiError::Unauthorized) +} + +/// For what only the account does (its password, its passkeys). +pub(crate) async fn the_account(state: &AppState, user: &AuthUser) -> ApiResult<()> { + if current(state, user).await?.account_id.is_some() { + return Err(ApiError::Refused { + code: "account_only", + message: "Only the main profile can do this.".into(), + }); + } + Ok(()) +} + +async fn views(state: &AppState, me: &User) -> ApiResult> { + let profiles = iris_db::users::profiles_of(state.db(), me.account()).await?; + Ok(profiles + .into_iter() + .map(|p| ProfileView { + id: p.id.into(), + main: p.account_id.is_none(), + current: p.id == me.id, + name: p.display_name, + }) + .collect()) +} + +#[utoipa::path( + get, + path = "/api/me/profiles", + operation_id = "list_profiles", + responses((status = 200, description = "The account's profiles, itself first", body = [ProfileView])), + tag = "me", +)] +pub(crate) async fn list( + State(state): State, + user: AuthUser, +) -> ApiResult>> { + let me = current(&state, &user).await?; + Ok(Json(views(&state, &me).await?)) +} + +#[utoipa::path( + post, + path = "/api/me/profiles", + operation_id = "create_profile", + request_body = CreateProfileRequest, + responses( + (status = 200, description = "The account's profiles, the new one included", body = [ProfileView]), + (status = 400, description = "Empty or too long a name"), + (status = 409, description = "The account holds as many profiles as it can"), + ), + tag = "me", +)] +pub(crate) async fn create( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult>> { + let name = crate::routes::me::checked_display_name(&body.name)?; + let me = current(&state, &user).await?; + if iris_db::users::create_profile(state.db(), me.account(), name) + .await? + .is_none() + { + return Err(ApiError::Refused { + code: "profiles_full", + message: format!( + "An account holds {} profiles at most.", + iris_db::users::MAX_PROFILES + ), + }); + } + Ok(Json(views(&state, &me).await?)) +} + +#[utoipa::path( + delete, + path = "/api/me/profiles/{id}", + operation_id = "remove_profile", + params(("id" = Uuid, Path, description = "The profile to remove")), + responses( + (status = 204, description = "Profile removed; what it grabbed stays in the library"), + (status = 404, description = "No such profile on this account"), + (status = 409, description = "The main profile, or the one in use"), + ), + tag = "me", +)] +pub(crate) async fn remove( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + let me = current(&state, &user).await?; + let target = sibling(&state, &me, UserId::from(id)).await?; + if target.account_id.is_none() || target.id == me.id { + return Err(ApiError::Refused { + code: "profile_kept", + message: "The main profile and the one in use stay.".into(), + }); + } + iris_db::users::delete(state.db(), target.id, me.account()).await?; + state.session_cuts().forget(); + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// `id` when it is a profile of the caller's account. +async fn sibling(state: &AppState, me: &User, id: UserId) -> ApiResult { + iris_db::users::find_by_id(state.db(), id) + .await? + .filter(|u| u.account() == me.account()) + .ok_or(ApiError::NotFound) +} + +/// Under `/api/auth`, the only path the refresh cookie is sent to: the session +/// being left is ended, and a paired device keeps its label on the new one. +#[utoipa::path( + post, + path = "/api/auth/switch-profile", + request_body = SwitchProfileRequest, + responses( + (status = 200, description = "On the other profile; new session cookies set", body = UserResponse), + (status = 404, description = "No such profile on this account"), + ), + tag = "auth", +)] +pub(crate) async fn switch( + State(state): State, + jar: CookieJar, + user: AuthUser, + Json(body): Json, +) -> ApiResult<(CookieJar, Json)> { + let me = current(&state, &user).await?; + let target = sibling(&state, &me, UserId::from(body.profile_id)).await?; + + let leaving = jar + .get(REFRESH_COOKIE) + .and_then(|c| state.jwt().verify_refresh(c.value()).ok()) + .map(|claims| claims.jti); + let device = match leaving { + Some(jti) => iris_db::refresh_tokens::device_of(state.db(), jti).await?, + None => None, + }; + let (label, kind) = device.unwrap_or_default(); + + let new = + crate::routes::auth::new_session(&state, target.id, label.as_deref(), kind.as_deref()); + iris_db::refresh_tokens::insert_session(state.db(), &new).await?; + if let Some(jti) = leaving { + iris_db::refresh_tokens::revoke_for_user(state.db(), me.id, jti).await?; + } + let jar = crate::routes::auth::session_cookies( + &state, + &jar, + target.id, + target.is_admin, + &crate::routes::auth::stored(&new), + )?; + Ok((jar, Json(target.into()))) +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use serde_json::json; + + use crate::routes::auth::tests::{PASSWORD, app_with_member, call, login}; + + #[tokio::test] + async fn a_profile_is_its_own_person_on_the_same_account() { + let (state, app, account, email) = app_with_member().await; + let signed_in = login(&app, &email, PASSWORD).await; + let access = signed_in.cookie("iris_access").unwrap(); + let refresh = signed_in.cookie("iris_refresh").unwrap(); + let both = format!("{access}; {refresh}"); + + let made = call( + &app, + "POST", + "/api/me/profiles", + None, + Some(&both), + Some(json!({ "name": "Muriel" })), + ) + .await; + assert_eq!(made.status, StatusCode::OK); + let muriel = made.json[1]["id"].as_str().unwrap().to_owned(); + assert_eq!(made.json[0]["main"], true); + assert_eq!(made.json[1]["current"], false); + + let switched = call( + &app, + "POST", + "/api/auth/switch-profile", + None, + Some(&both), + Some(json!({ "profile_id": muriel })), + ) + .await; + assert_eq!(switched.status, StatusCode::OK); + assert_eq!(switched.json["display_name"], "Muriel"); + assert_eq!(switched.json["is_admin"], false); + assert_eq!( + call( + &app, + "POST", + "/api/auth/refresh", + None, + Some(&refresh), + None + ) + .await + .status, + StatusCode::UNAUTHORIZED, + "the session left is over" + ); + + let as_muriel = switched.cookie("iris_access").unwrap(); + let on_muriel = call( + &app, + "GET", + "/api/me/profiles", + None, + Some(&as_muriel), + None, + ) + .await; + assert_eq!(on_muriel.json[1]["current"], true); + assert_eq!( + call( + &app, + "POST", + "/api/me/password", + None, + Some(&as_muriel), + Some(json!({ "old_password": "x", "new_password": "a brand new secret" })), + ) + .await + .status, + StatusCode::CONFLICT, + "the password is the account's" + ); + + let back = state.jwt().issue_access(account, false).unwrap(); + let path = format!("/api/me/profiles/{muriel}"); + assert_eq!( + call(&app, "DELETE", &path, None, Some(&as_muriel), None) + .await + .status, + StatusCode::CONFLICT, + "not the one in use" + ); + assert_eq!( + call(&app, "DELETE", &path, Some(&back), None, None) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!( + call(&app, "GET", "/api/me", None, Some(&as_muriel), None) + .await + .status, + StatusCode::UNAUTHORIZED, + "a removed profile's session is over" + ); + } + + #[tokio::test] + async fn a_stranger_is_not_a_profile() { + let (state, app, _, email) = app_with_member().await; + let other = iris_db::test_support::make_user(state.db()).await; + let access = login(&app, &email, PASSWORD) + .await + .cookie("iris_access") + .unwrap(); + let id = uuid::Uuid::from(other); + let tried = call( + &app, + "POST", + "/api/auth/switch-profile", + None, + Some(&access), + Some(json!({ "profile_id": id })), + ) + .await; + assert_eq!(tried.status, StatusCode::NOT_FOUND); + let path = format!("/api/me/profiles/{id}"); + assert_eq!( + call(&app, "DELETE", &path, None, Some(&access), None) + .await + .status, + StatusCode::NOT_FOUND + ); + } +} diff --git a/crates/iris-api/src/routes/release_note.rs b/crates/iris-api/src/routes/release_note.rs new file mode 100644 index 00000000..c9539b1c --- /dev/null +++ b/crates/iris-api/src/routes/release_note.rs @@ -0,0 +1,69 @@ +//! The one sentence said after an update, read from `CHANGELOG.md` at build +//! time: the quote line right under the running version's heading. + +use axum::Json; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::routes::extract::AuthUser; + +const CHANGELOG: &str = include_str!("../../../../CHANGELOG.md"); + +#[derive(Debug, Serialize, ToSchema)] +pub struct ReleaseNote { + /// The version this server runs. + pub version: String, + /// What changed, in a sentence. Absent when the release has none to say. + pub note: Option, +} + +#[utoipa::path( + get, + path = "/api/release-note", + operation_id = "releaseNote", + responses((status = 200, description = "The running version and its update sentence", body = ReleaseNote)), + tag = "health", +)] +pub async fn get(_user: AuthUser) -> Json { + let version = env!("CARGO_PKG_VERSION"); + Json(ReleaseNote { + version: version.to_owned(), + note: sentence_of(CHANGELOG, version).map(str::to_owned), + }) +} + +fn sentence_of<'a>(changelog: &'a str, version: &str) -> Option<&'a str> { + let heading = format!("## [{version}]"); + let mut lines = changelog.lines().skip_while(|l| !l.starts_with(&heading)); + lines.next()?; + let first = lines.find(|l| !l.trim().is_empty())?; + let sentence = first.strip_prefix('>')?.trim(); + (!sentence.is_empty()).then_some(sentence) +} + +#[cfg(test)] +mod tests { + use super::{CHANGELOG, sentence_of}; + + const LOG: &str = "## [Unreleased]\n\n## [2.0.0] - 2026-11-02\n\n> Two more trackers.\n\n### Added\n\n- x\n\n## [1.9.0] - 2026-10-01\n\n### Fixed\n\n> not a note\n"; + + #[test] + fn reads_the_quote_right_under_the_heading() { + assert_eq!(sentence_of(LOG, "2.0.0"), Some("Two more trackers.")); + } + + #[test] + fn a_release_without_a_quote_says_nothing() { + assert_eq!(sentence_of(LOG, "1.9.0"), None); + } + + #[test] + fn an_unknown_version_says_nothing() { + assert_eq!(sentence_of(LOG, "3.0.0"), None); + } + + #[test] + fn the_changelog_is_embedded() { + assert!(CHANGELOG.contains("## [1.6.4]")); + } +} diff --git a/crates/iris-api/src/session_cut.rs b/crates/iris-api/src/session_cut.rs index bed6bbcf..8b5b7aea 100644 --- a/crates/iris-api/src/session_cut.rs +++ b/crates/iris-api/src/session_cut.rs @@ -55,11 +55,12 @@ impl SessionCuts { Ok(()) } - /// After a committed write to the user's row (cut stamped, account - /// deleted): the next request reads it again. - pub fn forget(&self, user: UserId) { + /// After a committed write to users' rows (cut stamped, account deleted, + /// and an account's profiles move with it): the next requests read them + /// again. + pub fn forget(&self) { let mut cuts = self.inner.write().unwrap_or_else(PoisonError::into_inner); - cuts.by_user.remove(&user); + cuts.by_user.clear(); cuts.generation += 1; } } @@ -80,7 +81,7 @@ mod tests { iris_db::users::set_password(&pool, user, "h") .await .unwrap(); - cuts.forget(user); + cuts.forget(); assert!(matches!( cuts.check(&pool, user, before).await, Err(ApiError::Unauthorized) diff --git a/crates/iris-core/src/user.rs b/crates/iris-core/src/user.rs index 172020cd..6f56fae4 100644 --- a/crates/iris-core/src/user.rs +++ b/crates/iris-core/src/user.rs @@ -13,6 +13,16 @@ pub struct User { pub display_name: String, pub is_admin: bool, pub created_at: DateTime, + /// The account this profile hangs from; `None` for the account itself. + #[serde(default)] + pub account_id: Option, +} + +impl User { + /// The account behind this user, itself when it is the account. + pub fn account(&self) -> UserId { + self.account_id.unwrap_or(self.id) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/iris-db/src/collections.rs b/crates/iris-db/src/collections.rs index a9616c13..d32108ec 100644 --- a/crates/iris-db/src/collections.rs +++ b/crates/iris-db/src/collections.rs @@ -508,6 +508,7 @@ pub async fn merge_into( WHERE collection_id = ?1", "UPDATE OR IGNORE cw_dismissed SET collection_id = ?2 WHERE collection_id = ?1", "UPDATE OR IGNORE ghost_dismissed SET collection_id = ?2 WHERE collection_id = ?1", + "UPDATE OR IGNORE friends_hidden SET collection_id = ?2 WHERE collection_id = ?1", ] { sqlx::query(sql) .bind(loser.id) diff --git a/crates/iris-db/src/friends.rs b/crates/iris-db/src/friends.rs new file mode 100644 index 00000000..a783a7e4 --- /dev/null +++ b/crates/iris-db/src/friends.rs @@ -0,0 +1,480 @@ +//! Friendships between profiles, what a profile shares with them, and the +//! activity they see. + +use chrono::{DateTime, Utc}; +use iris_core::ids::UserId; +use sqlx::SqlitePool; +use uuid::Uuid; + +/// Who a profile's accepted friends are, for `?1` the profile. +macro_rules! friends_cte { + () => { + "WITH friends(id) AS ( \ + SELECT addressee_id FROM friendships WHERE requester_id = ?1 AND accepted_at IS NOT NULL \ + UNION \ + SELECT requester_id FROM friendships WHERE addressee_id = ?1 AND accepted_at IS NOT NULL) " + }; +} + +/// Keeps a row of torrent `t` its owner `$owner` did not hide. +macro_rules! not_hidden { + ($owner:literal) => { + concat!( + " NOT EXISTS (SELECT 1 FROM friends_hidden h WHERE h.user_id = ", + $owner, + " AND (h.collection_id = t.collection_id OR h.infohash = t.infohash)) " + ) + }; +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Person { + pub id: Uuid, + pub name: String, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Relation { + pub id: Uuid, + pub name: String, + pub since: DateTime, + pub accepted: bool, + pub incoming: bool, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct HiddenTitle { + pub collection_id: Option, + pub infohash: Option, + pub title: String, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Title { + pub collection_id: Option, + pub infohash: String, + pub title: String, + pub season: Option, + pub episode: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Event { + pub friend_id: Uuid, + pub friend_name: String, + pub at: DateTime, + #[sqlx(flatten)] + pub title: Title, +} + +/// Ask `to`; when `to` already asked, that is a yes both ways. Asking again +/// changes nothing, and a declined asker is told nothing. +pub async fn request(pool: &SqlitePool, from: UserId, to: UserId) -> Result<(), sqlx::Error> { + let (from, to): (Uuid, Uuid) = (from.into(), to.into()); + let now = Utc::now(); + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let answered = sqlx::query( + "UPDATE friendships SET accepted_at = COALESCE(accepted_at, ?1), declined_at = NULL \ + WHERE requester_id = ?2 AND addressee_id = ?3", + ) + .bind(now) + .bind(to) + .bind(from) + .execute(&mut *tx) + .await? + .rows_affected(); + if answered == 0 { + sqlx::query( + "INSERT OR IGNORE INTO friendships (requester_id, addressee_id, created_at) \ + VALUES (?1, ?2, ?3)", + ) + .bind(from) + .bind(to) + .bind(now) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +/// `true` when `other` had asked `me`, and now they are friends. +pub async fn accept(pool: &SqlitePool, me: UserId, other: UserId) -> Result { + let res = sqlx::query( + "UPDATE friendships SET accepted_at = ?1 \ + WHERE requester_id = ?2 AND addressee_id = ?3 \ + AND accepted_at IS NULL AND declined_at IS NULL", + ) + .bind(Utc::now()) + .bind(Uuid::from(other)) + .bind(Uuid::from(me)) + .execute(pool) + .await?; + Ok(res.rows_affected() == 1) +} + +pub async fn decline(pool: &SqlitePool, me: UserId, other: UserId) -> Result { + let res = sqlx::query( + "UPDATE friendships SET declined_at = ?1 \ + WHERE requester_id = ?2 AND addressee_id = ?3 \ + AND accepted_at IS NULL AND declined_at IS NULL", + ) + .bind(Utc::now()) + .bind(Uuid::from(other)) + .bind(Uuid::from(me)) + .execute(pool) + .await?; + Ok(res.rows_affected() == 1) +} + +/// End a friendship, or take back one's own request. A request `me` declined +/// stays, or asking again would get past the decline. +pub async fn remove(pool: &SqlitePool, me: UserId, other: UserId) -> Result { + let res = sqlx::query( + "DELETE FROM friendships \ + WHERE (requester_id = ?1 AND addressee_id = ?2) \ + OR (requester_id = ?2 AND addressee_id = ?1 AND accepted_at IS NOT NULL)", + ) + .bind(Uuid::from(me)) + .bind(Uuid::from(other)) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) +} + +/// Friends and live requests. What `me` declined is gone from its side; what +/// was declined to `me` still reads as pending. +pub async fn relations(pool: &SqlitePool, me: UserId) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT u.id, u.display_name AS name, COALESCE(f.accepted_at, f.created_at) AS since, \ + f.accepted_at IS NOT NULL AS accepted, f.addressee_id = ?1 AS incoming \ + FROM friendships f \ + JOIN users u ON u.id = CASE WHEN f.requester_id = ?1 THEN f.addressee_id ELSE f.requester_id END \ + WHERE (f.requester_id = ?1 OR f.addressee_id = ?1) \ + AND NOT (f.addressee_id = ?1 AND f.declined_at IS NOT NULL) \ + ORDER BY u.display_name COLLATE NOCASE", + ) + .bind(Uuid::from(me)) + .fetch_all(pool) + .await +} + +/// How many requests wait for `me`. +pub async fn incoming_count(pool: &SqlitePool, me: UserId) -> Result { + let (n,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM friendships \ + WHERE addressee_id = ?1 AND accepted_at IS NULL AND declined_at IS NULL", + ) + .bind(Uuid::from(me)) + .fetch_one(pool) + .await?; + Ok(n) +} + +/// Profiles by name for `me` to ask, none it is already linked to either way. +pub async fn search( + pool: &SqlitePool, + me: UserId, + query: &str, + limit: i64, +) -> Result, sqlx::Error> { + let escaped = query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + sqlx::query_as( + "SELECT u.id, u.display_name AS name FROM users u \ + WHERE u.id <> ?1 AND u.display_name LIKE '%' || ?2 || '%' ESCAPE '\\' \ + AND NOT EXISTS (SELECT 1 FROM friendships f \ + WHERE (f.requester_id = ?1 AND f.addressee_id = u.id) \ + OR (f.requester_id = u.id AND f.addressee_id = ?1)) \ + ORDER BY u.display_name COLLATE NOCASE LIMIT ?3", + ) + .bind(Uuid::from(me)) + .bind(escaped) + .bind(limit) + .fetch_all(pool) + .await +} + +pub async fn sharing(pool: &SqlitePool, me: UserId) -> Result { + let (on,): (bool,) = sqlx::query_as("SELECT share_watching FROM users WHERE id = ?1") + .bind(Uuid::from(me)) + .fetch_one(pool) + .await?; + Ok(on) +} + +pub async fn set_sharing(pool: &SqlitePool, me: UserId, on: bool) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE users SET share_watching = ?1 WHERE id = ?2") + .bind(on) + .bind(Uuid::from(me)) + .execute(pool) + .await?; + Ok(()) +} + +/// Keep a release's whole title from friends. `false`: no such release. +pub async fn hide(pool: &SqlitePool, me: UserId, infohash: &str) -> Result { + let res = sqlx::query( + "INSERT OR IGNORE INTO friends_hidden (user_id, collection_id, infohash, created_at) \ + SELECT ?1, t.collection_id, CASE WHEN t.collection_id IS NULL THEN t.infohash END, ?2 \ + FROM torrents t WHERE t.infohash = ?3", + ) + .bind(Uuid::from(me)) + .bind(Utc::now()) + .bind(infohash) + .execute(pool) + .await?; + let (known,): (bool,) = + sqlx::query_as("SELECT EXISTS (SELECT 1 FROM torrents WHERE infohash = ?1)") + .bind(infohash) + .fetch_one(pool) + .await?; + Ok(res.rows_affected() > 0 || known) +} + +pub async fn unhide( + pool: &SqlitePool, + me: UserId, + collection_id: Option, + infohash: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "DELETE FROM friends_hidden \ + WHERE user_id = ?1 AND (collection_id = ?2 OR infohash = ?3)", + ) + .bind(Uuid::from(me)) + .bind(collection_id) + .bind(infohash) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn hidden(pool: &SqlitePool, me: UserId) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT h.collection_id, h.infohash, COALESCE(c.display_title, t.name, '') AS title \ + FROM friends_hidden h \ + LEFT JOIN collections c ON c.id = h.collection_id \ + LEFT JOIN torrents t ON t.infohash = h.infohash \ + WHERE h.user_id = ?1 ORDER BY h.created_at DESC", + ) + .bind(Uuid::from(me)) + .fetch_all(pool) + .await +} + +/// `me`'s friends who share what they watch. +pub async fn sharing_friends(pool: &SqlitePool, me: UserId) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + friends_cte!(), + "SELECT u.id, u.display_name AS name FROM users u \ + JOIN friends ON friends.id = u.id WHERE u.share_watching" + )) + .bind(Uuid::from(me)) + .fetch_all(pool) + .await +} + +/// What `owner` is playing, unless kept from friends. +pub async fn shown_title( + pool: &SqlitePool, + owner: Uuid, + infohash: &str, + file_idx: i64, +) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + "SELECT t.collection_id, t.infohash, COALESCE(c.display_title, t.name) AS title, \ + ef.season, ef.episode \ + FROM torrents t \ + LEFT JOIN collections c ON c.id = t.collection_id \ + LEFT JOIN episode_files ef ON ef.infohash = t.infohash AND ef.file_idx = ?3 \ + WHERE t.infohash = ?2 AND", + not_hidden!("?1") + )) + .bind(owner) + .bind(infohash) + .bind(file_idx) + .fetch_optional(pool) + .await +} + +/// What friends who share watched last, newest first. +pub async fn recently_watched( + pool: &SqlitePool, + me: UserId, + limit: i64, +) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + friends_cte!(), + "SELECT u.id AS friend_id, u.display_name AS friend_name, p.last_watched_at AS at, \ + t.collection_id, t.infohash, COALESCE(c.display_title, t.name) AS title, \ + ef.season, ef.episode \ + FROM playback_progress p \ + JOIN friends ON friends.id = p.user_id \ + JOIN users u ON u.id = p.user_id AND u.share_watching \ + JOIN torrents t ON t.infohash = p.infohash \ + LEFT JOIN collections c ON c.id = t.collection_id \ + LEFT JOIN episode_files ef ON ef.infohash = p.infohash AND ef.file_idx = p.file_idx \ + WHERE", + not_hidden!("p.user_id"), + "ORDER BY p.last_watched_at DESC LIMIT ?2" + )) + .bind(Uuid::from(me)) + .bind(limit) + .fetch_all(pool) + .await +} + +/// What friends added to the library last, newest first. +pub async fn recently_added( + pool: &SqlitePool, + me: UserId, + limit: i64, +) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + friends_cte!(), + "SELECT u.id AS friend_id, u.display_name AS friend_name, t.added_at AS at, \ + t.collection_id, t.infohash, COALESCE(c.display_title, t.name) AS title, \ + NULL AS season, NULL AS episode \ + FROM torrents t \ + JOIN friends ON friends.id = t.added_by \ + JOIN users u ON u.id = t.added_by \ + LEFT JOIN collections c ON c.id = t.collection_id \ + WHERE t.deleted_at IS NULL AND", + not_hidden!("t.added_by"), + "ORDER BY t.added_at DESC LIMIT ?2" + )) + .bind(Uuid::from(me)) + .bind(limit) + .fetch_all(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{make_named_user, make_user, migrated_pool}; + + async fn torrent(pool: &SqlitePool, infohash: &str, owner: UserId) { + sqlx::query( + "INSERT INTO torrents (id, infohash, name, total_size_bytes, added_by, added_at) \ + VALUES (?1, ?2, ?3, 1, ?4, ?5)", + ) + .bind(Uuid::new_v4()) + .bind(infohash) + .bind(format!("Release.{infohash}")) + .bind(Uuid::from(owner)) + .bind(Utc::now()) + .execute(pool) + .await + .unwrap(); + } + + async fn watch(pool: &SqlitePool, user: UserId, infohash: &str) { + sqlx::query( + "INSERT INTO playback_progress (user_id, infohash, file_idx, last_watched_at) \ + VALUES (?1, ?2, 0, ?3)", + ) + .bind(Uuid::from(user)) + .bind(infohash) + .bind(Utc::now()) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn a_declined_asker_is_told_nothing_and_cannot_ask_again() { + let pool = migrated_pool().await; + let (a, b) = (make_user(&pool).await, make_user(&pool).await); + request(&pool, a, b).await.unwrap(); + assert_eq!(incoming_count(&pool, b).await.unwrap(), 1); + assert!(decline(&pool, b, a).await.unwrap()); + assert_eq!(incoming_count(&pool, b).await.unwrap(), 0); + assert!(relations(&pool, b).await.unwrap().is_empty()); + let asker = relations(&pool, a).await.unwrap(); + assert!(!asker[0].accepted && !asker[0].incoming); + + request(&pool, a, b).await.unwrap(); + assert_eq!(incoming_count(&pool, b).await.unwrap(), 0); + assert!(!remove(&pool, b, a).await.unwrap(), "the decline stays"); + } + + #[tokio::test] + async fn asking_someone_who_asked_is_a_yes() { + let pool = migrated_pool().await; + let (a, b) = (make_user(&pool).await, make_user(&pool).await); + request(&pool, a, b).await.unwrap(); + request(&pool, b, a).await.unwrap(); + assert!(relations(&pool, a).await.unwrap()[0].accepted); + assert!(relations(&pool, b).await.unwrap()[0].accepted); + } + + #[tokio::test] + async fn search_finds_by_name_and_leaves_out_who_is_linked() { + let pool = migrated_pool().await; + let me = make_named_user(&pool, "Vincent").await; + let muriel = make_named_user(&pool, "Muriel").await; + make_named_user(&pool, "100%").await; + let names = |v: Vec| v.into_iter().map(|p| p.name).collect::>(); + assert_eq!( + names(search(&pool, me, "uri", 20).await.unwrap()), + ["Muriel"] + ); + assert_eq!(names(search(&pool, me, "%", 20).await.unwrap()), ["100%"]); + assert!(search(&pool, me, "vin", 20).await.unwrap().is_empty()); + request(&pool, muriel, me).await.unwrap(); + assert!(search(&pool, me, "uri", 20).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn watching_reaches_friends_only_once_shared_and_never_when_hidden() { + let pool = migrated_pool().await; + let (me, friend, stranger) = ( + make_user(&pool).await, + make_user(&pool).await, + make_user(&pool).await, + ); + torrent(&pool, "aaaa", friend).await; + torrent(&pool, "bbbb", stranger).await; + watch(&pool, friend, "aaaa").await; + watch(&pool, stranger, "bbbb").await; + request(&pool, friend, me).await.unwrap(); + set_sharing(&pool, stranger, true).await.unwrap(); + + assert!( + recently_added(&pool, me, 10).await.unwrap().is_empty(), + "asked, not accepted" + ); + accept(&pool, me, friend).await.unwrap(); + + assert_eq!(recently_added(&pool, me, 10).await.unwrap().len(), 1); + assert!(recently_watched(&pool, me, 10).await.unwrap().is_empty()); + set_sharing(&pool, friend, true).await.unwrap(); + let watched = recently_watched(&pool, me, 10).await.unwrap(); + assert_eq!(watched[0].title.infohash, "aaaa"); + assert_eq!(watched.len(), 1, "a stranger shares nothing with me"); + assert_eq!(sharing_friends(&pool, me).await.unwrap().len(), 1); + + assert!(hide(&pool, friend, "aaaa").await.unwrap()); + assert!(recently_watched(&pool, me, 10).await.unwrap().is_empty()); + assert!(recently_added(&pool, me, 10).await.unwrap().is_empty()); + assert!( + shown_title(&pool, friend.into(), "aaaa", 0) + .await + .unwrap() + .is_none() + ); + assert_eq!(hidden(&pool, friend).await.unwrap().len(), 1); + unhide(&pool, friend, None, Some("aaaa")).await.unwrap(); + assert_eq!(recently_watched(&pool, me, 10).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn hiding_an_unknown_release_is_refused() { + let pool = migrated_pool().await; + let me = make_user(&pool).await; + assert!(!hide(&pool, me, "nope").await.unwrap()); + } +} diff --git a/crates/iris-db/src/lib.rs b/crates/iris-db/src/lib.rs index e919d66f..16aa2177 100644 --- a/crates/iris-db/src/lib.rs +++ b/crates/iris-db/src/lib.rs @@ -39,6 +39,7 @@ pub mod collections; pub mod device_codes; pub mod episode_files; pub mod follows; +pub mod friends; pub mod invitations; pub mod migrate; pub mod passkeys; diff --git a/crates/iris-db/src/refresh_tokens.rs b/crates/iris-db/src/refresh_tokens.rs index f1b3f9b9..3efec146 100644 --- a/crates/iris-db/src/refresh_tokens.rs +++ b/crates/iris-db/src/refresh_tokens.rs @@ -126,7 +126,9 @@ pub async fn list_devices_for_user( sqlx::query_as::<_, RefreshToken>( "SELECT jti, user_id, issued_at, expires_at, revoked_at, device_label, device_kind \ FROM refresh_tokens \ - WHERE user_id = ?1 AND revoked_at IS NULL AND device_kind IS NOT NULL \ + WHERE user_id IN (SELECT id FROM users WHERE COALESCE(account_id, id) = \ + (SELECT COALESCE(account_id, id) FROM users WHERE id = ?1)) \ + AND revoked_at IS NULL AND device_kind IS NOT NULL \ AND expires_at > ?2 \ ORDER BY issued_at DESC", ) @@ -136,8 +138,20 @@ pub async fn list_devices_for_user( .await } +/// A session's device label and kind, to carry over when the session changes +/// profile. +pub async fn device_of( + pool: &SqlitePool, + jti: Uuid, +) -> Result, Option)>, sqlx::Error> { + sqlx::query_as("SELECT device_label, device_kind FROM refresh_tokens WHERE jti = ?1") + .bind(jti) + .fetch_optional(pool) + .await +} + /// End the session `jti` belongs to, every token of its family, when it is -/// one of `user_id`'s. `false`: no such session for this user. +/// held on `user_id`'s account, whichever profile. `false`: no such session. pub async fn revoke_for_user( pool: &SqlitePool, user_id: UserId, @@ -146,9 +160,11 @@ pub async fn revoke_for_user( let user: Uuid = user_id.into(); let res = sqlx::query( "UPDATE refresh_tokens SET revoked_at = COALESCE(revoked_at, ?1), rotated_at = NULL \ - WHERE user_id = ?3 AND family_id = \ + WHERE family_id = \ (SELECT family_id FROM refresh_tokens \ - WHERE jti = ?2 AND user_id = ?3 AND revoked_at IS NULL)", + WHERE jti = ?2 AND revoked_at IS NULL AND user_id IN \ + (SELECT id FROM users WHERE COALESCE(account_id, id) = \ + (SELECT COALESCE(account_id, id) FROM users WHERE id = ?3)))", ) .bind(Utc::now()) .bind(jti) diff --git a/crates/iris-db/src/users.rs b/crates/iris-db/src/users.rs index bdafd915..4d8390ac 100644 --- a/crates/iris-db/src/users.rs +++ b/crates/iris-db/src/users.rs @@ -12,6 +12,7 @@ struct UserRow { display_name: String, is_admin: bool, created_at: DateTime, + account_id: Option, } impl UserRow { @@ -23,6 +24,7 @@ impl UserRow { display_name: self.display_name, is_admin: self.is_admin, created_at: self.created_at, + account_id: self.account_id.map(UserId::from), }, self.password_hash, ) @@ -78,9 +80,56 @@ where display_name, is_admin: new.is_admin, created_at: now, + account_id: None, }) } +/// How many profiles an account holds, itself included. +pub const MAX_PROFILES: usize = 6; + +/// A new profile under `account`, `None` once it holds [`MAX_PROFILES`]. Its +/// email only fills the unique column and its hash verifies nothing: a profile +/// is entered from its account, never signed in to. +pub async fn create_profile( + pool: &SqlitePool, + account: UserId, + name: &str, +) -> Result, sqlx::Error> { + let owner: Uuid = account.into(); + let id = Uuid::new_v4(); + let now = Utc::now(); + let email = format!("{id}@profile.invalid"); + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let (held,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM users WHERE id = ?1 OR account_id = ?1") + .bind(owner) + .fetch_one(&mut *tx) + .await?; + if usize::try_from(held).unwrap_or(usize::MAX) >= MAX_PROFILES { + return Ok(None); + } + sqlx::query( + "INSERT INTO users (id, email, password_hash, display_name, is_admin, created_at, account_id) \ + VALUES (?1, ?2, '!', ?3, 0, ?4, ?5)", + ) + .bind(id) + .bind(&email) + .bind(name) + .bind(now) + .bind(owner) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(User { + id: UserId::from(id), + email, + display_name: name.to_owned(), + is_admin: false, + created_at: now, + account_id: Some(account), + })) +} + /// Column list for `SELECT`ing a [`UserRow`]. A macro rather than a /// `const &str` so it expands to a string literal usable inside /// `concat!`, keeping every read query a compile-time `&'static str` — @@ -90,10 +139,25 @@ where /// construction. macro_rules! user_columns { () => { - "id, email, password_hash, display_name, is_admin, created_at" + "id, email, password_hash, display_name, is_admin, created_at, account_id" }; } +/// The account's profiles, itself first, then by age. +pub async fn profiles_of(pool: &SqlitePool, account: UserId) -> Result, sqlx::Error> { + let owner: Uuid = account.into(); + let rows: Vec = sqlx::query_as(concat!( + "SELECT ", + user_columns!(), + " FROM users WHERE id = ?1 OR account_id = ?1 \ + ORDER BY account_id IS NOT NULL, created_at ASC" + )) + .bind(owner) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|r| r.into_domain().0).collect()) +} + pub async fn find_by_email<'e, E>( executor: E, email: &str, @@ -104,7 +168,7 @@ where let row: Option = sqlx::query_as(concat!( "SELECT ", user_columns!(), - " FROM users WHERE email = ?1" + " FROM users WHERE email = ?1 AND account_id IS NULL" )) .bind(email) .fetch_optional(executor) @@ -126,7 +190,7 @@ pub async fn find_by_id(pool: &SqlitePool, id: UserId) -> Result, s } pub async fn count(pool: &SqlitePool) -> Result { - let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE account_id IS NULL") .fetch_one(pool) .await?; Ok(n) @@ -136,7 +200,7 @@ pub async fn list(pool: &SqlitePool) -> Result, sqlx::Error> { let rows: Vec = sqlx::query_as(concat!( "SELECT ", user_columns!(), - " FROM users ORDER BY created_at ASC" + " FROM users WHERE account_id IS NULL ORDER BY created_at ASC" )) .fetch_all(pool) .await?; @@ -159,7 +223,8 @@ pub async fn get_password_hash( /// under the old one, in one transaction: every session (a just-rotated /// one loses its grace window too, and `sessions_valid_after` kills the /// access tokens already out) and every passkey, since a stolen session -/// could have registered its own. +/// could have registered its own. The account's profiles go through the same +/// door, so their sessions end with it. /// /// Returns `None` for an unknown user, else how many passkeys went. pub async fn set_password( @@ -180,9 +245,14 @@ pub async fn set_password( if res.rows_affected() != 1 { return Ok(None); } + sqlx::query("UPDATE users SET sessions_valid_after = ?1 WHERE account_id = ?2") + .bind(now) + .bind(uuid) + .execute(&mut *tx) + .await?; sqlx::query( "UPDATE refresh_tokens SET revoked_at = COALESCE(revoked_at, ?1), rotated_at = NULL \ - WHERE user_id = ?2", + WHERE user_id IN (SELECT id FROM users WHERE id = ?2 OR account_id = ?2)", ) .bind(now) .bind(uuid) @@ -202,7 +272,8 @@ pub async fn set_password( /// and the library is household-shared, so letting the cascade run would /// silently evict every release the deleted user ever grabbed. Everything /// personal (sessions, watch progress, follows, preferences, dismissals) -/// cascades away as intended. +/// cascades away as intended. An account's profiles leave with it, and their +/// grabs are inherited the same way. pub async fn delete( pool: &SqlitePool, id: UserId, @@ -211,11 +282,14 @@ pub async fn delete( let target: Uuid = id.into(); let heir: Uuid = reassign_torrents_to.into(); let mut tx = pool.begin().await?; - sqlx::query("UPDATE torrents SET added_by = ?1 WHERE added_by = ?2") - .bind(heir) - .bind(target) - .execute(&mut *tx) - .await?; + sqlx::query( + "UPDATE torrents SET added_by = ?1 \ + WHERE added_by IN (SELECT id FROM users WHERE id = ?2 OR account_id = ?2)", + ) + .bind(heir) + .bind(target) + .execute(&mut *tx) + .await?; let res = sqlx::query("DELETE FROM users WHERE id = ?1") .bind(target) .execute(&mut *tx) @@ -393,6 +467,95 @@ mod tests { assert_eq!(rows[0].actor_display_name, "deleted user"); } + #[tokio::test] + async fn a_profile_cannot_be_signed_in_to_and_stays_out_of_the_accounts() { + let pool = migrated_pool().await; + let account = make_user(&pool, "shared@t.test").await; + let profile = create_profile(&pool, account, "Muriel") + .await + .unwrap() + .unwrap(); + assert_eq!(profile.account(), account); + + assert!( + find_by_email(&pool, &profile.email) + .await + .unwrap() + .is_none() + ); + assert_eq!(count(&pool).await.unwrap(), 1); + assert_eq!(list(&pool).await.unwrap().len(), 1); + let names: Vec<_> = profiles_of(&pool, account) + .await + .unwrap() + .into_iter() + .map(|u| u.display_name) + .collect(); + assert_eq!(names, ["shared", "Muriel"]); + } + + #[tokio::test] + async fn an_account_holds_a_bounded_number_of_profiles() { + let pool = migrated_pool().await; + let account = make_user(&pool, "shared@t.test").await; + for _ in 1..MAX_PROFILES { + assert!(create_profile(&pool, account, "p").await.unwrap().is_some()); + } + assert!(create_profile(&pool, account, "p").await.unwrap().is_none()); + } + + #[tokio::test] + async fn a_password_change_ends_the_sessions_of_every_profile() { + let pool = migrated_pool().await; + let account = make_user(&pool, "shared@t.test").await; + let profile = create_profile(&pool, account, "Muriel") + .await + .unwrap() + .unwrap(); + let jti = Uuid::new_v4(); + crate::refresh_tokens::insert( + &pool, + jti, + profile.id, + Utc::now() + chrono::Duration::days(1), + ) + .await + .unwrap(); + + set_password(&pool, account, "new-hash").await.unwrap(); + + assert!(!crate::refresh_tokens::is_active(&pool, jti).await.unwrap()); + assert!( + sessions_valid_after(&pool, profile.id) + .await + .unwrap() + .flatten() + .is_some() + ); + } + + #[tokio::test] + async fn deleting_an_account_keeps_the_grabs_of_its_profiles() { + let pool = migrated_pool().await; + let admin = make_user(&pool, "admin@t.test").await; + let account = make_user(&pool, "shared@t.test").await; + let profile = create_profile(&pool, account, "Muriel") + .await + .unwrap() + .unwrap(); + insert_torrent(&pool, "bbbb", profile.id).await; + + assert!(delete(&pool, account, admin).await.unwrap()); + + assert!(find_by_id(&pool, profile.id).await.unwrap().is_none()); + let (owner,): (Uuid,) = + sqlx::query_as("SELECT added_by FROM torrents WHERE infohash = 'bbbb'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(UserId::from(owner), admin); + } + #[tokio::test] async fn delete_missing_user_is_false() { let pool = migrated_pool().await; diff --git a/migrations/0053_profiles.sql b/migrations/0053_profiles.sql new file mode 100644 index 00000000..3c3aeac1 --- /dev/null +++ b/migrations/0053_profiles.sql @@ -0,0 +1,8 @@ +-- A profile is a users row of its own, so everything personal (progress, +-- follows, preferences) stays keyed on users(id). `account_id` names the +-- account it hangs from; NULL is the account itself. Such a row has no way in +-- of its own: sign-in looks up accounts only. ADD COLUMN, never a users +-- rebuild: DROP TABLE would fire every ON DELETE CASCADE pointing here. +ALTER TABLE users ADD COLUMN account_id BLOB REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX users_account_idx ON users(account_id) WHERE account_id IS NOT NULL; diff --git a/migrations/0054_friends.sql b/migrations/0054_friends.sql new file mode 100644 index 00000000..7e5a16d0 --- /dev/null +++ b/migrations/0054_friends.sql @@ -0,0 +1,33 @@ +-- Friendships hang on profiles (users rows). A declined request keeps its row: +-- the asker keeps seeing it pending, is told nothing, and cannot ask again. +CREATE TABLE friendships ( + requester_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE, + addressee_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL, + accepted_at TIMESTAMP, + declined_at TIMESTAMP, + PRIMARY KEY (requester_id, addressee_id), + CHECK (requester_id <> addressee_id) +); + +CREATE INDEX friendships_addressee_idx ON friendships(addressee_id); +CREATE UNIQUE INDEX friendships_pair_idx + ON friendships(min(requester_id, addressee_id), max(requester_id, addressee_id)); + +-- What a profile watches reaches its friends only once it says so. What it +-- adds to the library was already visible to everyone ("added by"). +ALTER TABLE users ADD COLUMN share_watching BOOLEAN NOT NULL DEFAULT 0; + +-- Titles kept from friends: by collection, by release when it has none. +CREATE TABLE friends_hidden ( + user_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE, + collection_id BLOB REFERENCES collections(id) ON DELETE CASCADE, + infohash TEXT, + created_at TIMESTAMP NOT NULL, + CHECK ((collection_id IS NULL) <> (infohash IS NULL)) +); + +CREATE UNIQUE INDEX friends_hidden_collection_idx + ON friends_hidden(user_id, collection_id) WHERE collection_id IS NOT NULL; +CREATE UNIQUE INDEX friends_hidden_release_idx + ON friends_hidden(user_id, infohash) WHERE infohash IS NOT NULL; diff --git a/packages/iris-api/src/client.ts b/packages/iris-api/src/client.ts index dedb178f..8cf59bfa 100644 --- a/packages/iris-api/src/client.ts +++ b/packages/iris-api/src/client.ts @@ -9,6 +9,13 @@ declare const __IRIS_WEB_VERSION__: string; * under the existing names so call sites don't change; the rest stays * hand-written until its endpoints are annotated in later waves. */ export type User = components['schemas']['UserResponse']; +export type ReleaseNote = components['schemas']['ReleaseNote']; +export type ProfileView = components['schemas']['ProfileView']; +export type PersonView = components['schemas']['PersonView']; +export type HiddenTitleView = components['schemas']['HiddenTitleView']; +export type FriendsOverview = components['schemas']['FriendsOverview']; +export type FriendEvent = components['schemas']['FriendEvent']; +export type FriendActivity = components['schemas']['FriendActivity']; /** The longest name the server accepts (display names, passkey names), in bytes. */ export const MAX_NAME = 64; @@ -482,6 +489,29 @@ export type ForYouResponse = components['schemas']['ForYou']; export type MoodTile = components['schemas']['MoodTile']; export type MoodBoard = components['schemas']['MoodBoard']; export type MoodResults = components['schemas']['MoodResults']; +export const profiles = { + list: () => api.get('/me/profiles'), + create: (name: string) => api.post('/me/profiles', { name }), + remove: (id: string) => api.delete(`/me/profiles/${seg(id)}`), + switchTo: (profile_id: string) => api.post('/auth/switch-profile', { profile_id }) +}; + +export const friends = { + overview: () => api.get('/me/friends'), + search: (q: string) => api.get(`/me/friends/search?q=${seg(q)}`), + activity: () => api.get('/me/friends/activity'), + ask: (id: string) => api.post(`/me/friends/${seg(id)}`), + accept: (id: string) => api.post(`/me/friends/${seg(id)}/accept`), + decline: (id: string) => api.post(`/me/friends/${seg(id)}/decline`), + remove: (id: string) => api.delete(`/me/friends/${seg(id)}`), + setSharing: (share_watching: boolean) => api.put('/me/friends/sharing', { share_watching }), + hide: (infohash: string) => api.post('/me/friends/hidden', { infohash }), + unhide: (title: Pick) => + request('DELETE', '/me/friends/hidden', { collection_id: title.collection_id, infohash: title.infohash }) +}; + +export const releaseNote = () => api.get('/release-note'); + export const me = { /** `include_grabbable` opts into synthesised "next episode isn't on * disk yet" tiles (`grabbable: true`, empty infohash) — the web bundle diff --git a/packages/iris-api/src/mascot.test.ts b/packages/iris-api/src/mascot.test.ts new file mode 100644 index 00000000..b8897cae --- /dev/null +++ b/packages/iris-api/src/mascot.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { MASCOT_ANTENNAS, MASCOT_EYES, MASCOT_FEET, MASCOT_SCREENS, mascot, mascotHash } from './mascot'; + +/** The Android TV app's `MascotTest` holds these same values: one name, one mascot. */ +const parts = (name: string) => { + const m = mascot(name); + return [ + m.tile, + MASCOT_ANTENNAS.indexOf(m.antenna as never), + MASCOT_EYES.indexOf(m.eyes as never), + MASCOT_SCREENS.indexOf(m.screen as never), + MASCOT_FEET.indexOf(m.foot as never) + ]; +}; + +describe('mascot', () => { + it('a name always draws the same one, whatever its case or spaces', () => { + expect(mascot(' Théo ')).toEqual(mascot('théo')); + }); + + it('the vectors shared with the TV', () => { + expect(mascotHash('Vincent')).toBe(4095504808); + expect(parts('Vincent')).toEqual([0, 1, 1, 1, 2]); + expect(parts('Muriel')).toEqual([3, 1, 1, 2, 1]); + expect(parts('Uplg')).toEqual([1, 3, 3, 1, 2]); + expect(parts('Théo')).toEqual([6, 0, 3, 2, 2]); + expect(parts('')).toEqual([5, 0, 4, 0, 0]); + }); +}); diff --git a/packages/iris-api/src/mascot.ts b/packages/iris-api/src/mascot.ts new file mode 100644 index 00000000..909d940a --- /dev/null +++ b/packages/iris-api/src/mascot.ts @@ -0,0 +1,58 @@ +// A person's mascot, picked from their name: the Iris mark's grammar (tile, antenna, screen, +// blush, foot, and no mouth), on its 48-unit grid. The same name draws the same mascot everywhere: +// the Android TV app holds this table and this hash too (`ui/components/Mascot.kt`), and the test +// vectors in both must stay equal. + +export const MASCOT_TILES = 8; + +export const MASCOT_ANTENNAS = ['M19 8.5l5 6 5-6', 'M24 14.5V9.5M24 6.5v.01', 'M18 14.5l-2-6M30 14.5l2-6', 'M24 14.5l5-6 4 2'] as const; + +export const MASCOT_EYES = [ + 'M16.4 25.4a2.3 2.3 0 0 1 4.4 0M27.2 25.4a2.3 2.3 0 0 1 4.4 0', + 'M16.4 24a2.3 2.3 0 0 0 4.4 0M27.2 24a2.3 2.3 0 0 0 4.4 0', + 'M18.6 23.2v2.8M29.4 23.2v2.8', + 'M16.4 25.4a2.3 2.3 0 0 1 4.4 0M29.4 23.2v2.8', + 'M16.2 24.8h4.8M27 24.8h4.8', + 'M16.6 23.4l4 1.6M31.4 23.4l-4 1.6' +] as const; + +export const MASCOT_SCREENS = [ + 'M15.5 15h17a6 6 0 0 1 6 6v10a6 6 0 0 1-6 6h-17a6 6 0 0 1-6-6v-10a6 6 0 0 1 6-6z', + 'M12.5 15h23a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3h-23a3 3 0 0 1-3-3v-16a3 3 0 0 1 3-3z', + 'M19.5 15h9a10 10 0 0 1 10 10v2a10 10 0 0 1-10 10h-9a10 10 0 0 1-10-10v-2a10 10 0 0 1 10-10z' +] as const; + +export const MASCOT_FEET = ['M19 41h10', 'M15 38v3M33 38v3', 'M14 41h20'] as const; + +export const MASCOT_BLUSH = 'M13.2 29.6a2 1.2 0 1 0 4 0a2 1.2 0 1 0-4 0zM30.8 29.6a2 1.2 0 1 0 4 0a2 1.2 0 1 0-4 0z'; + +export interface Mascot { + tile: number; + antenna: string; + eyes: string; + screen: string; + foot: string; +} + +/** FNV-1a over the UTF-8 of the name, trimmed and in lower case: « Théo » and « théo » are one. */ +export function mascotHash(name: string): number { + let h = 0x811c9dc5; + for (const byte of new TextEncoder().encode(name.trim().toLowerCase())) h = Math.imul(h ^ byte, 0x01000193); + return h >>> 0; +} + +export function mascot(name: string): Mascot { + let h = mascotHash(name); + const take = (n: number, from: (i: number) => T): T => { + const i = h % n; + h = Math.floor(h / n); + return from(i); + }; + return { + tile: take(MASCOT_TILES, (i) => i), + antenna: take(MASCOT_ANTENNAS.length, (i) => MASCOT_ANTENNAS[i]), + eyes: take(MASCOT_EYES.length, (i) => MASCOT_EYES[i]), + screen: take(MASCOT_SCREENS.length, (i) => MASCOT_SCREENS[i]), + foot: take(MASCOT_FEET.length, (i) => MASCOT_FEET[i]) + }; +} diff --git a/web/openapi.json b/web/openapi.json index 311a8a07..6d941790 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -1058,6 +1058,40 @@ } } }, + "/api/auth/switch-profile": { + "post": { + "tags": [ + "auth" + ], + "summary": "Under `/api/auth`, the only path the refresh cookie is sent to: the session\nbeing left is ended, and a paired device keeps its label on the new one.", + "operationId": "switch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchProfileRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "On the other profile; new session cookies set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "404": { + "description": "No such profile on this account" + } + } + } + }, "/api/discover/featured": { "get": { "tags": [ @@ -2291,6 +2325,260 @@ } } }, + "/api/me/friends": { + "get": { + "tags": [ + "friends" + ], + "operationId": "friends_overview", + "responses": { + "200": { + "description": "Friends, requests and what is shared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FriendsOverview" + } + } + } + } + } + } + }, + "/api/me/friends/activity": { + "get": { + "tags": [ + "friends" + ], + "operationId": "friends_activity", + "responses": { + "200": { + "description": "What friends watch and add", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FriendActivity" + } + } + } + } + } + } + }, + "/api/me/friends/hidden": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_hide", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HideRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The release's whole title is kept from friends" + }, + "404": { + "description": "No such release" + } + } + }, + "delete": { + "tags": [ + "friends" + ], + "operationId": "friends_unhide", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnhideRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Friends can see it again" + } + } + } + }, + "/api/me/friends/search": { + "get": { + "tags": [ + "friends" + ], + "operationId": "friends_search", + "parameters": [ + { + "name": "q", + "in": "query", + "description": "Part of a display name, two characters at least.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Profiles to ask, by name; never an email", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + } + } + } + } + } + } + } + }, + "/api/me/friends/sharing": { + "put": { + "tags": [ + "friends" + ], + "operationId": "friends_set_sharing", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharingRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Saved" + } + } + } + }, + "/api/me/friends/{id}": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_ask", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The profile to ask", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Asked; when they had asked first, you are friends" + }, + "404": { + "description": "No such profile" + } + } + }, + "delete": { + "tags": [ + "friends" + ], + "operationId": "friends_remove", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The friend, or who was asked", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Friendship ended or request taken back" + }, + "404": { + "description": "Nothing to remove" + } + } + } + }, + "/api/me/friends/{id}/accept": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_accept", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Who asked", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "You are friends" + }, + "404": { + "description": "No such request" + } + } + } + }, + "/api/me/friends/{id}/decline": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_decline", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Who asked", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Declined; they are not told" + }, + "404": { + "description": "No such request" + } + } + } + }, "/api/me/gone/dismiss": { "post": { "tags": [ @@ -2720,8 +3008,99 @@ } } }, - "400": { - "description": "Unknown language in the request" + "400": { + "description": "Unknown language in the request" + } + } + } + }, + "/api/me/profiles": { + "get": { + "tags": [ + "me" + ], + "operationId": "list_profiles", + "responses": { + "200": { + "description": "The account's profiles, itself first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileView" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "me" + ], + "operationId": "create_profile", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProfileRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The account's profiles, the new one included", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileView" + } + } + } + } + }, + "400": { + "description": "Empty or too long a name" + }, + "409": { + "description": "The account holds as many profiles as it can" + } + } + } + }, + "/api/me/profiles/{id}": { + "delete": { + "tags": [ + "me" + ], + "operationId": "remove_profile", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The profile to remove", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Profile removed; what it grabbed stays in the library" + }, + "404": { + "description": "No such profile on this account" + }, + "409": { + "description": "The main profile, or the one in use" } } } @@ -3020,6 +3399,26 @@ } } }, + "/api/release-note": { + "get": { + "tags": [ + "health" + ], + "operationId": "releaseNote", + "responses": { + "200": { + "description": "The running version and its update sentence", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseNote" + } + } + } + } + } + } + }, "/api/search": { "get": { "tags": [ @@ -5146,6 +5545,17 @@ } } }, + "CreateProfileRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "CreatedInvitation": { "type": "object", "required": [ @@ -5791,6 +6201,129 @@ } } }, + "FriendAction": { + "type": "string", + "enum": [ + "watching", + "watched", + "added" + ] + }, + "FriendActivity": { + "type": "object", + "required": [ + "live", + "recent" + ], + "properties": { + "live": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FriendEvent" + }, + "description": "What friends are playing this minute." + }, + "recent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FriendEvent" + }, + "description": "What they watched and added lately, newest first." + } + } + }, + "FriendEvent": { + "type": "object", + "required": [ + "friend_id", + "friend_name", + "action", + "title", + "at" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/FriendAction" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "collection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "episode": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "friend_id": { + "type": "string", + "format": "uuid" + }, + "friend_name": { + "type": "string" + }, + "season": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "type": "string" + } + } + }, + "FriendsOverview": { + "type": "object", + "required": [ + "friends", + "incoming", + "outgoing", + "share_watching", + "hidden" + ], + "properties": { + "friends": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + } + }, + "hidden": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HiddenTitleView" + } + }, + "incoming": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + }, + "description": "Requests waiting for this profile's answer." + }, + "outgoing": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + }, + "description": "Requests this profile sent, still unanswered as far as it can tell." + }, + "share_watching": { + "type": "boolean", + "description": "Whether friends see what this profile watches." + } + } + }, "GcReport": { "type": "object", "required": [ @@ -6067,6 +6600,41 @@ "hlg" ] }, + "HiddenTitleView": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "collection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "infohash": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + } + } + }, + "HideRequest": { + "type": "object", + "required": [ + "infohash" + ], + "properties": { + "infohash": { + "type": "string" + } + } + }, "HistoryItem": { "type": "object", "description": "One row of the caller's full watch history (in-progress AND completed),\nincluding items whose source torrent has since been deleted\n(disk-reclaim GC, admin cleanup) — unlike [`ContinueWatchingItem`], an\nentry never just vanishes; `deleted` flags it instead so the client can\nshow \"no longer available\" rather than a dead resume link.", @@ -6239,6 +6807,14 @@ "format": "double", "description": "Overall progress of those downloads, by bytes (0–100)." }, + "friend_requests": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Friend requests waiting for this profile's answer." + }, "new_episodes": { "type": "integer", "format": "int64", @@ -7391,6 +7967,22 @@ } } }, + "PersonView": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + }, "PlayStatus": { "type": "object", "description": "State of the per-file remux job exposed to the player UI. Polled\nbefore mounting the `