From 55b0da685ca71458aba58b98d3af836726ffd16d Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 2 Oct 2026 12:55:30 +0300 Subject: [PATCH 1/2] fix(tv): hand VLC the account download token instead of the access token Open in VLC built /files/{id}/stream with the session's full OAuth token. It now loads the account's download token through the same account info query playback uses; a missing token is a typed failure with no fallback. --- .../android/tv/TvAutoplayProofTest.kt | 3 +- .../io/putdotio/android/tv/TvProofSession.kt | 3 +- .../android/files/FilesWatchedRepository.kt | 55 +++++++++++-- .../android/playback/PlaybackRepository.kt | 9 ++- .../android/files/SdkFilesStreamUrlsTest.kt | 80 +++++++++++++++++++ .../android/tv/TvSessionViewModelTest.kt | 10 ++- .../tv/kotlin/io/putdotio/android/PutioApp.kt | 28 +++++-- .../putdotio/android/tv/TvSessionViewModel.kt | 12 ++- .../android/tv/files/TvFilesAction.kt | 2 +- app/src/tv/res/values/strings.xml | 1 + docs/harness.md | 6 +- 11 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 app/src/test/kotlin/io/putdotio/android/files/SdkFilesStreamUrlsTest.kt diff --git a/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvAutoplayProofTest.kt b/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvAutoplayProofTest.kt index f9e2d719..34ec3663 100644 --- a/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvAutoplayProofTest.kt +++ b/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvAutoplayProofTest.kt @@ -26,6 +26,7 @@ import io.putdotio.android.files.FilesItemResolver import io.putdotio.android.files.FilesPage import io.putdotio.android.files.FilesPlaybackProgress import io.putdotio.android.files.FilesRepositoryResult +import io.putdotio.android.files.FilesStreamUrlResult import io.putdotio.android.files.FilesStreamUrls import io.putdotio.android.files.FilesWatchedRepository import io.putdotio.android.history.HistoryEventId @@ -251,7 +252,7 @@ class TvAutoplayProofTest { override suspend fun clearPosition(itemId: FilesItemId) = FilesRepositoryResult.Success(Unit) }, - streamUrls = FilesStreamUrls { null }, + streamUrls = FilesStreamUrls { FilesStreamUrlResult.DownloadTokenUnavailable }, filesItemResolver = object : FilesItemResolver { override suspend fun resolveItem(itemId: FilesItemId) = error("No history rows") }, diff --git a/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvProofSession.kt b/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvProofSession.kt index e4bee978..3feed647 100644 --- a/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvProofSession.kt +++ b/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvProofSession.kt @@ -18,6 +18,7 @@ import io.putdotio.android.files.FilesPage import io.putdotio.android.files.FilesRepository import io.putdotio.android.files.FilesRepositoryResult import io.putdotio.android.files.FilesSort +import io.putdotio.android.files.FilesStreamUrlResult import io.putdotio.android.files.FilesStreamUrls import io.putdotio.android.files.FilesWatchedRepository import io.putdotio.android.history.HistoryEventId @@ -116,7 +117,7 @@ internal fun tvProofDependencies( override suspend fun clearPosition(itemId: FilesItemId) = FilesRepositoryResult.Success(Unit) }, - streamUrls = FilesStreamUrls { null }, + streamUrls = FilesStreamUrls { FilesStreamUrlResult.DownloadTokenUnavailable }, filesItemResolver = object : FilesItemResolver { override suspend fun resolveItem(itemId: FilesItemId) = error("No history rows") }, diff --git a/app/src/main/kotlin/io/putdotio/android/files/FilesWatchedRepository.kt b/app/src/main/kotlin/io/putdotio/android/files/FilesWatchedRepository.kt index fd58cae6..07b7926c 100644 --- a/app/src/main/kotlin/io/putdotio/android/files/FilesWatchedRepository.kt +++ b/app/src/main/kotlin/io/putdotio/android/files/FilesWatchedRepository.kt @@ -1,6 +1,8 @@ package io.putdotio.android.files +import io.putdotio.android.playback.loadMediaAccount import io.putdotio.sdk.PutioClient +import io.putdotio.sdk.account.AccountInfo import io.putdotio.sdk.errors.PutioException import java.util.concurrent.CancellationException @@ -37,15 +39,54 @@ class SdkFilesWatchedRepository( } } -/** URLs another player can open; they carry the session's token and must never be logged or stored. */ +/** + * URLs another player can open. They carry the account's download token, never the session's + * access token, and must never be logged or stored. + */ fun interface FilesStreamUrls { - /** The original file's stream URL, or null while no session token is set. */ - fun originalStreamUrl(itemId: FilesItemId): String? + suspend fun originalStreamUrl(itemId: FilesItemId): FilesStreamUrlResult } -class SdkFilesStreamUrls( - private val client: PutioClient, +sealed interface FilesStreamUrlResult { + class Ready( + val url: String, + ) : FilesStreamUrlResult { + override fun toString(): String = "Ready()" + } + + /** put.io returned no download token; the session's access token is never a substitute. */ + data object DownloadTokenUnavailable : FilesStreamUrlResult + + data class Failure( + val failure: FilesFailure, + ) : FilesStreamUrlResult +} + +class SdkFilesStreamUrls internal constructor( + private val loadAccount: suspend () -> AccountInfo, + private val buildOriginalStreamUrl: (fileId: Long, downloadToken: String) -> String, ) : FilesStreamUrls { - override fun originalStreamUrl(itemId: FilesItemId): String? = - client.config.accessToken?.let { client.files.buildOriginalStreamUrl(itemId.value, it) } + constructor(client: PutioClient) : this( + loadAccount = client::loadMediaAccount, + // The SDK names this parameter accessToken; put.io accepts the download token on /stream. + buildOriginalStreamUrl = { fileId, token -> client.files.buildOriginalStreamUrl(fileId, token) }, + ) + + // This SDK boundary converts unexpected implementation failures into the app's stable failure taxonomy. + @Suppress("TooGenericExceptionCaught") + override suspend fun originalStreamUrl(itemId: FilesItemId): FilesStreamUrlResult = + try { + val token = loadAccount().downloadToken + if (token == null) { + FilesStreamUrlResult.DownloadTokenUnavailable + } else { + FilesStreamUrlResult.Ready(buildOriginalStreamUrl(itemId.value, token.value)) + } + } catch (error: CancellationException) { + throw error + } catch (error: PutioException) { + FilesStreamUrlResult.Failure(error.toFilesFailure()) + } catch (unexpected: Exception) { + FilesStreamUrlResult.Failure(FilesFailure.Unexpected(unexpected)) + } } diff --git a/app/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt b/app/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt index 076ab70a..498648a5 100644 --- a/app/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt +++ b/app/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt @@ -157,7 +157,7 @@ class SdkPlaybackRepository internal constructor( playbackPreference: () -> PlaybackPreference, ) : this( playbackPreference = playbackPreference, - loadAccount = { client.account.getInfo(AccountInfoQuery(downloadToken = true)) }, + loadAccount = client::loadMediaAccount, resolvePlayback = client.files::resolvePlayback, loadFile = { fileId -> client.files.get( @@ -339,6 +339,13 @@ class ConvertingPlaybackRepository internal constructor( private const val AUTOPLAY_PAGE_SIZE = 200 +/** + * The account with its download token, the narrow credential put.io accepts on media endpoints. + * Every media URL the app builds carries this token, never the session's access token. + */ +internal suspend fun PutioClient.loadMediaAccount(): AccountInfo = + account.getInfo(AccountInfoQuery(downloadToken = true)) + internal class MissingPlaybackCredentialException : IllegalStateException("Playback credential is unavailable") private fun io.putdotio.sdk.files.PlaybackResolution.toAppResolution( diff --git a/app/src/test/kotlin/io/putdotio/android/files/SdkFilesStreamUrlsTest.kt b/app/src/test/kotlin/io/putdotio/android/files/SdkFilesStreamUrlsTest.kt new file mode 100644 index 00000000..ac3d0e64 --- /dev/null +++ b/app/src/test/kotlin/io/putdotio/android/files/SdkFilesStreamUrlsTest.kt @@ -0,0 +1,80 @@ +package io.putdotio.android.files + +import io.putdotio.sdk.PutioClient +import io.putdotio.sdk.PutioConfig +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SdkFilesStreamUrlsTest { + @Test + fun externalPlayerUrlsCarryTheDownloadTokenNeverTheAccessToken() = runBlocking { + val requests = mutableListOf() + val client = client(requests, downloadToken = "\"$DOWNLOAD_TOKEN\"") + + val result = SdkFilesStreamUrls(client).originalStreamUrl(FilesItemId(42L)) + + val url = (result as FilesStreamUrlResult.Ready).url + assertEquals("https://api.put.io/v2/files/42/stream?oauth_token=$DOWNLOAD_TOKEN", url) + assertFalse(url.contains(ACCESS_TOKEN)) + assertFalse(result.toString().contains(DOWNLOAD_TOKEN)) + assertTrue(requests.single().endsWith("/account/info?download_token=1")) + } + + @Test + fun aMissingDownloadTokenFailsInsteadOfFallingBackToTheAccessToken() = runBlocking { + val result = SdkFilesStreamUrls(client(mutableListOf(), downloadToken = null)) + .originalStreamUrl(FilesItemId(42L)) + + assertEquals(FilesStreamUrlResult.DownloadTokenUnavailable, result) + } + + @Test + fun aRejectedSessionIsTheSessionVerdict() = runBlocking { + val result = SdkFilesStreamUrls(client(mutableListOf(), downloadToken = null, status = 401)) + .originalStreamUrl(FilesItemId(42L)) + + assertTrue((result as FilesStreamUrlResult.Failure).failure is FilesFailure.AuthenticationRequired) + } + + private fun client(requests: MutableList, downloadToken: String?, status: Int = 200): PutioClient { + val body = if (status == 200) accountInfo(downloadToken) else UNAUTHORIZED + val http = OkHttpClient.Builder() + .addInterceptor( + Interceptor { chain -> + requests += chain.request().url.toString() + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(status) + .message("") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + }, + ) + .build() + return PutioClient(PutioConfig(accessToken = ACCESS_TOKEN), http) + } + + private fun accountInfo(downloadToken: String?): String = + """ + {"status":"OK","info":{"user_id":1,"username":"u","mail":"u@example.com","avatar_url":"", + "disk":{"avail":1,"size":1,"used":0},"settings":{"sort_by":"NAME_ASC"}, + "account_status":"active","download_token":${downloadToken ?: "null"}}} + """.trimIndent() + + private companion object { + const val ACCESS_TOKEN = "full-oauth-access-token" + const val DOWNLOAD_TOKEN = "narrow-download-token" + const val UNAUTHORIZED = + """{"status":"ERROR","error_type":"invalid_grant","error_message":"Unauthorized","status_code":401}""" + } +} diff --git a/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt b/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt index ee636231..71c2de0c 100644 --- a/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt +++ b/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt @@ -12,6 +12,7 @@ import io.putdotio.android.files.FilesItemResolver import io.putdotio.android.files.FilesOpenOrigin import io.putdotio.android.files.FilesPage import io.putdotio.android.files.FilesRepositoryResult +import io.putdotio.android.files.FilesStreamUrlResult import io.putdotio.android.files.FilesStreamUrls import io.putdotio.android.files.FilesWatchedRepository import io.putdotio.android.files.StubFilesRepository @@ -128,7 +129,9 @@ class TvSessionViewModelTest { override suspend fun save(change: AndroidAppConfigChange) = AndroidAppConfigRepositoryResult.Success(Unit) }, watchedRepository = watched, - streamUrls = FilesStreamUrls { "https://api.put.io/v2/files/${it.value}/stream?oauth_token=t" }, + streamUrls = FilesStreamUrls { + FilesStreamUrlResult.Ready("https://api.put.io/v2/files/${it.value}/stream?oauth_token=t") + }, filesItemResolver = object : FilesItemResolver { override suspend fun resolveItem(itemId: FilesItemId) = FilesRepositoryResult.Success( FilesItem( @@ -287,7 +290,10 @@ class TvSessionViewModelTest { assertEquals(rejected, session.fileActionFailure.value) session.dismissFileActionFailure() assertEquals(rejected, session.fileActionFailure.value) - assertEquals("https://api.put.io/v2/files/9/stream?oauth_token=t", session.originalStreamUrl(video)) + assertEquals( + "https://api.put.io/v2/files/9/stream?oauth_token=t", + (session.originalStreamUrl(video) as FilesStreamUrlResult.Ready).url, + ) } @Test diff --git a/app/src/tv/kotlin/io/putdotio/android/PutioApp.kt b/app/src/tv/kotlin/io/putdotio/android/PutioApp.kt index a7be5d53..8e586ee6 100644 --- a/app/src/tv/kotlin/io/putdotio/android/PutioApp.kt +++ b/app/src/tv/kotlin/io/putdotio/android/PutioApp.kt @@ -21,6 +21,7 @@ import io.putdotio.android.files.FilesContent import io.putdotio.android.files.FilesFailure import io.putdotio.android.files.FilesItem import io.putdotio.android.files.FilesOpenOrigin +import io.putdotio.android.files.FilesStreamUrlResult import io.putdotio.android.files.SdkFilesRepository import io.putdotio.android.files.SdkFilesStreamUrls import io.putdotio.android.files.SdkFilesWatchedRepository @@ -292,7 +293,9 @@ internal fun TvSessionShell( // A row's actions: VLC gets the original file; a watched toggle writes the // account's position; deletion runs on the shared browser operation. val context = LocalContext.current + val streamScope = rememberCoroutineScope() var filesNotice by remember(session) { mutableStateOf(null) } + var streamFailure by remember(session) { mutableStateOf(null) } // A requester on the pane lands on its first focusable descendant (Refresh); the // pane's own entry effects then move focus to the row it remembers. TvFilesScreen( @@ -305,21 +308,34 @@ internal fun TvSessionShell( confirmedTrashEnabled = settingsState.confirmedTrashEnabled(), watchedToggleEnabled = settingsState.confirmedResumePlayback() == true, onOpenInVlc = { item -> - val url = session.originalStreamUrl(item) - filesNotice = when { - url == null -> R.string.tv_files_stream_unavailable - launchVlc(context, url, item) -> null - else -> R.string.tv_files_vlc_missing + streamScope.launch { + val stream = session.originalStreamUrl(item) + filesNotice = null + streamFailure = null + when (stream) { + is FilesStreamUrlResult.Ready -> + if (!launchVlc(context, stream.url, item)) filesNotice = R.string.tv_files_vlc_missing + FilesStreamUrlResult.DownloadTokenUnavailable -> + filesNotice = R.string.tv_files_stream_unavailable + // A 401 is the session's verdict, which the session already holds. + is FilesStreamUrlResult.Failure -> streamFailure = + stream.failure.takeUnless { it is FilesFailure.AuthenticationRequired } + } } }, onSetWatched = session::setWatched, notice = filesNotice?.let { stringResource(it) } + ?: streamFailure?.let { stringResource(R.string.tv_files_stream_error, it.tvMessageText()) } ?: fileActionFailure?.takeUnless { it is FilesFailure.AuthenticationRequired } ?.let { stringResource(R.string.tv_files_watched_error, it.tvMessageText()) }, // OK clears only what it was shown; a failure that arrived behind a VLC // notice is shown next. onDismissNotice = { - if (filesNotice != null) filesNotice = null else session.dismissFileActionFailure() + when { + filesNotice != null -> filesNotice = null + streamFailure != null -> streamFailure = null + else -> session.dismissFileActionFailure() + } }, ) }, diff --git a/app/src/tv/kotlin/io/putdotio/android/tv/TvSessionViewModel.kt b/app/src/tv/kotlin/io/putdotio/android/tv/TvSessionViewModel.kt index 2eb77931..c0e8604f 100644 --- a/app/src/tv/kotlin/io/putdotio/android/tv/TvSessionViewModel.kt +++ b/app/src/tv/kotlin/io/putdotio/android/tv/TvSessionViewModel.kt @@ -15,6 +15,7 @@ import io.putdotio.android.files.FilesOpenOrigin import io.putdotio.android.files.FilesPlaybackProgress import io.putdotio.android.files.FilesRepository import io.putdotio.android.files.FilesRepositoryResult +import io.putdotio.android.files.FilesStreamUrlResult import io.putdotio.android.files.FilesStreamUrls import io.putdotio.android.files.FilesWatchedRepository import io.putdotio.android.history.HistoryController @@ -273,8 +274,15 @@ internal class TvSession internal constructor( job.start() } - /** The original file's URL for an external player, or null without a session token. */ - fun originalStreamUrl(item: FilesItem): String? = streamUrls.originalStreamUrl(item.id) + /** + * The original file's URL for an external player, carrying the account's download token. + * A 401 is recorded as the session verdict; the caller explains every other failure. + */ + suspend fun originalStreamUrl(item: FilesItem): FilesStreamUrlResult = + streamUrls.originalStreamUrl(item.id).also { result -> + val failure = (result as? FilesStreamUrlResult.Failure)?.failure + if (failure is FilesFailure.AuthenticationRequired) mutableFileActionFailure.value = failure + } /** Drops the explanation the pane showed; a 401 stays, since it is a session verdict. */ fun dismissFileActionFailure() { diff --git a/app/src/tv/kotlin/io/putdotio/android/tv/files/TvFilesAction.kt b/app/src/tv/kotlin/io/putdotio/android/tv/files/TvFilesAction.kt index b6de10ae..dcefa0b0 100644 --- a/app/src/tv/kotlin/io/putdotio/android/tv/files/TvFilesAction.kt +++ b/app/src/tv/kotlin/io/putdotio/android/tv/files/TvFilesAction.kt @@ -110,7 +110,7 @@ internal fun TvFilesDeleteDialog( /** * Hands the original file to VLC. False when VLC is not installed; the URL carries the - * session token and goes only into the intent, never into a log or a message. + * account's download token and goes only into the intent, never into a log or a message. */ internal fun launchVlc(context: Context, streamUrl: String, item: FilesItem): Boolean { val intent = Intent(Intent.ACTION_VIEW) diff --git a/app/src/tv/res/values/strings.xml b/app/src/tv/res/values/strings.xml index 91f24364..6097b226 100644 --- a/app/src/tv/res/values/strings.xml +++ b/app/src/tv/res/values/strings.xml @@ -75,6 +75,7 @@ %1$s will be removed right away. This cannot be undone. VLC isn’t installed on this device. Couldn’t prepare the stream. Sign in again. + Couldn’t prepare the stream. %1$s Couldn’t update the watched state. %1$s Moving to trash Deleting diff --git a/docs/harness.md b/docs/harness.md index 6c900918..b36b5dd3 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -895,8 +895,10 @@ state: a centred dialog titled with the file's name, one full-width button per action and Cancel last, the first action focused; a row with no actions (a shared item that is not media, or a text row before the trash setting is confirmed) opens nothing, as in tv-native. Open in VLC hands the -original `/files/{id}/stream` URL to `org.videolan.vlc` with `ACTION_VIEW`; a -dialog explains when VLC is not installed. Mark as watched writes the video's +original `/files/{id}/stream` URL to `org.videolan.vlc` with `ACTION_VIEW`; the +URL carries the account's download token, never the session's access token, and +an account without one gets a notice instead of a URL. A dialog explains when +VLC is not installed. Mark as watched writes the video's duration as its position and Mark as unwatched clears it; both appear only when the account's `use_start_from` is confirmed on, and marking watched also needs a known duration. Move to trash or Delete permanently follows the From 65c8a8d03a1e53b23e0b440c191865c900c133c5 Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 2 Oct 2026 13:31:22 +0300 Subject: [PATCH 2/2] test(tv): cover stream lookup failures as session verdicts --- .../android/tv/TvSessionViewModelTest.kt | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt b/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt index 71c2de0c..d96cf751 100644 --- a/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt +++ b/app/src/testTv/kotlin/io/putdotio/android/tv/TvSessionViewModelTest.kt @@ -96,6 +96,9 @@ class TvSessionViewModelTest { private var historyItems = emptyList() /** The root listing's pages, continued by `page-N` cursors; empty lists an empty root. */ private var rootPages = emptyList() + private var streamResult: (FilesItemId) -> FilesStreamUrlResult = { + FilesStreamUrlResult.Ready("https://api.put.io/v2/files/${it.value}/stream?oauth_token=t") + } private val dependencies = TvSessionDependencies( filesRepository = object : StubFilesRepository() { override suspend fun loadFolder(folderId: FilesItemId): FilesRepositoryResult { @@ -129,9 +132,7 @@ class TvSessionViewModelTest { override suspend fun save(change: AndroidAppConfigChange) = AndroidAppConfigRepositoryResult.Success(Unit) }, watchedRepository = watched, - streamUrls = FilesStreamUrls { - FilesStreamUrlResult.Ready("https://api.put.io/v2/files/${it.value}/stream?oauth_token=t") - }, + streamUrls = FilesStreamUrls { streamResult(it) }, filesItemResolver = object : FilesItemResolver { override suspend fun resolveItem(itemId: FilesItemId) = FilesRepositoryResult.Success( FilesItem( @@ -296,6 +297,34 @@ class TvSessionViewModelTest { ) } + @Test + fun `a rejected stream lookup is the session verdict and survives dismissal`() = runTest { + val rejected = FilesFailure.AuthenticationRequired(PutioConfigurationException("401")) + streamResult = { FilesStreamUrlResult.Failure(rejected) } + val session = checkNotNull(TvSessionViewModel(auth).sessionFor(account(), TvAuthSessionId(1), dependencies)) + + val result = session.originalStreamUrl(streamItem) + + assertEquals(FilesStreamUrlResult.Failure(rejected), result) + assertEquals(rejected, session.fileActionFailure.value) + session.dismissFileActionFailure() + assertEquals(rejected, session.fileActionFailure.value) + } + + @Test + fun `a missing download token or other stream failure is no session verdict`() = runTest { + val session = checkNotNull(TvSessionViewModel(auth).sessionFor(account(), TvAuthSessionId(1), dependencies)) + + streamResult = { FilesStreamUrlResult.DownloadTokenUnavailable } + assertEquals(FilesStreamUrlResult.DownloadTokenUnavailable, session.originalStreamUrl(streamItem)) + assertNull(session.fileActionFailure.value) + + val offline = FilesStreamUrlResult.Failure(FilesFailure.Unexpected(IllegalStateException("offline"))) + streamResult = { offline } + assertEquals(offline, session.originalStreamUrl(streamItem)) + assertNull(session.fileActionFailure.value) + } + @Test fun `a session is refused when it is not the signed-in one`() { val viewModel = TvSessionViewModel(auth) @@ -601,4 +630,13 @@ class TvSessionViewModelTest { closed = true } } + + private val streamItem = FilesItem( + id = FilesItemId(9), + parentId = FilesItemId(0L), + name = "clip.mp4", + type = PutioFileType.VIDEO, + sizeBytes = 1L, + createdAt = "2026-04-20T10:00:00Z", + ) }