test: Add e2e release gate (script + manual checklist)

Releases need more than eyeballs: scripts/e2e-api-check.sh now runs
18 assertions over the live instance (login/refresh, listings, private
rows, subscriptions incl. remote channels, watching + stored position,
file token, playlist reachability) and TESTS.md defines the manual
D-pad checkout that a script cannot do.

Also switch the channels screen to the user's own channels (the
search-based local listing rejects empty queries) and resolve the real
account name at login since logins accept email addresses.
This commit is contained in:
Jeena 2026-10-10 11:02:22 +00:00
parent 764fa8b34c
commit 83dde8ba74
10 changed files with 284 additions and 8 deletions

View file

@ -67,6 +67,12 @@ class PeerTubeRepository(
expiresAtMillis = System.currentTimeMillis() +
(token.expiresIn ?: 86400L) * 1000L,
)
// Logins accept emails; resolve the real account name for /accounts/{handle}.
try {
store.accountName = api.me().username
} catch (_: Exception) {
store.accountName = username // best effort fallback
}
}
suspend fun me(): Result<UserDto> = runCatching { network.api.me() }
@ -110,6 +116,14 @@ class PeerTubeRepository(
suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result<Unit> =
runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) }
/**
* Short-lived URL token for non-public media. On instances storing media in a
* private object-storage bucket, playlist/segment URLs only accept this token as
* `?videoFileToken=`; the Bearer header only works for instance-served files.
*/
suspend fun videoToken(shortUuid: String): Result<String> =
runCatching { network.api.videoToken(shortUuid).files.token }
// ---- media URLs --------------------------------------------------------
/**

View file

@ -115,6 +115,17 @@ data class HistoryElement(
@SerialName("lastViewedAt") val lastViewedAt: String? = null,
)
@Serializable
data class VideoTokenResponse(
val files: VideoTokenFiles,
)
@Serializable
data class VideoTokenFiles(
val token: String,
val expires: String? = null,
)
@Serializable
data class UserDto(
val username: String,

View file

@ -103,6 +103,12 @@ interface PeerTubeApi {
@Body body: WatchingBody,
)
/** Short-lived token for private/internal media URLs (object storage safe). */
@POST("videos/{shortUuid}/token")
suspend fun videoToken(
@Path("shortUuid") shortUuid: String,
): VideoTokenResponse
@GET("users/me")
suspend fun me(): UserDto
}

View file

@ -48,6 +48,11 @@ class TokenStore(context: Context) {
get() = prefs.getString("refreshToken", null)
private set(value) = prefs.edit().putString("refreshToken", value).apply()
/** Real account name from users/me — may differ from the login input (email). */
var accountName: String?
get() = prefs.getString("accountName", null)
set(value) = prefs.edit().putString("accountName", value).apply()
var tokenExpiresAtMillis: Long
get() = prefs.getLong("tokenExpiresAtMillis", 0L)
private set(value) = prefs.edit().putLong("tokenExpiresAtMillis", value).apply()

View file

@ -32,20 +32,32 @@ import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.ui.list.PagedListViewModel
class ChannelsViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoChannelDto>() {
class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel<VideoChannelDto>() {
private var cached: List<VideoChannelDto>? = null
override suspend fun fetch(start: Long, count: Int): Result<List<VideoChannelDto>> =
repository.localChannels(start, count).map { it.data }
runCatching {
val existing = cached
if (existing == null) {
// MVP: the user's own channels (fully local). A paged, searchable
// local-channels listing needs a non-empty search term server-side.
val response = AppGraph.repository.accountChannels(
store.accountName ?: store.username ?: "",
)
response.fold(onSuccess = { c -> cached = c; c }, onFailure = { throw it })
} else existing
}
companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ChannelsViewModel(AppGraph.repository) as T
ChannelsViewModel(AppGraph.tokenStore) as T
}
}
}

View file

@ -60,7 +60,18 @@ class PlayerViewModel(
if (play == null) {
_state.value = PlayerUiState.Error("No playable stream found")
} else {
_state.value = PlayerUiState.Ready(detail, play.url, play.isHls)
// Non-public media may need a short-lived file token instead of
// (or in addition to) the Bearer header — matters on instances
// whose object-storage bucket rejects anonymous requests.
var url = play.url
if (detail.privacy?.id != 1) {
repository.videoToken(detail.shortUUID).onSuccess { token ->
url += (if (url.contains('?')) "&" else "?") +
"videoFileToken=" +
java.net.URLEncoder.encode(token, "UTF-8")
}
}
_state.value = PlayerUiState.Ready(detail, url, play.isHls)
}
},
onFailure = { e ->