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:
parent
764fa8b34c
commit
83dde8ba74
10 changed files with 284 additions and 8 deletions
|
|
@ -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 --------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue