Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,9 @@ interface TraktApi {
@Path("type") type: String,
@Query("extended") extended: String = "full",
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 100
@Query("limit") limit: Int = 100,
/** See listFreshnessMarker: keeps a day-long cache lifetime from hiding list edits. */
@Query("_") freshness: Long? = null
): List<TraktPublicListItem>

@GET("lists/{listId}")
Expand All @@ -481,7 +483,9 @@ interface TraktApi {
@Path("type") type: String,
@Query("extended") extended: String = "full",
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 100
@Query("limit") limit: Int = 100,
/** See listFreshnessMarker: keeps a day-long cache lifetime from hiding list edits. */
@Query("_") freshness: Long? = null
): List<TraktPublicListItem>

// ========== User Profile ==========
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/repository/ListFreshness.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.arflix.tv.data.repository

/**
* Lists a user edits themselves (Trakt and MDBList lists) are served with long cache lifetimes:
* Trakt allows a client to keep a list's items for a day, and MDBList's servers keep handing out
* the same copy for about as long. Followed as-is, an edit took up to a day to reach a home row.
*
* Requests for those lists carry a marker that changes every [LIST_REFRESH_MS]. A new marker is a
* new address, so neither the app's HTTP cache nor the service's own cache can answer it with an
* older copy, while loads within one window still share a single cached response.
*/
internal const val LIST_REFRESH_MS = 30 * 60 * 1000L

internal fun listFreshnessMarker(nowMs: Long = System.currentTimeMillis()): Long = nowMs / LIST_REFRESH_MS

internal fun withListFreshness(url: String, nowMs: Long = System.currentTimeMillis()): String {
val separator = if ('?' in url) '&' else '?'
return "$url${separator}_=${listFreshnessMarker(nowMs)}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -1427,7 +1427,7 @@ class MediaRepository @Inject constructor(
val slug = source.mdblistSlug?.trim()?.trim('/').orEmpty()
if (slug.isBlank()) return emptyList()
val body = withContext(Dispatchers.IO) {
fetchUrl("https://mdblist.com/lists/$slug/json")
fetchUrl(withListFreshness("https://mdblist.com/lists/$slug/json"))
} ?: throw java.io.IOException("Collection list could not be loaded")
val array = org.json.JSONArray(body)
val refs = mutableListOf<Pair<MediaType, Int>>()
Expand Down Expand Up @@ -3316,10 +3316,12 @@ class MediaRepository @Inject constructor(
val rows = when (parsed) {
is ParsedCatalogUrl.TraktUserList -> traktApi.getUserListItems(
clientId = Constants.TRAKT_CLIENT_ID, username = parsed.username,
listId = parsed.listId, type = type, page = page, limit = 100)
listId = parsed.listId, type = type, page = page, limit = 100,
freshness = listFreshnessMarker())
is ParsedCatalogUrl.TraktList -> traktApi.getListItems(
clientId = Constants.TRAKT_CLIENT_ID, listId = parsed.listId,
type = type, page = page, limit = 100)
type = type, page = page, limit = 100,
freshness = listFreshnessMarker())
else -> emptyList()
}
if (rows == previous) break
Expand Down Expand Up @@ -3418,7 +3420,7 @@ class MediaRepository @Inject constructor(
}
val url = sourceUrl ?: return emptyList()

val jsonUrl = "${url.removeSuffix("/")}/json"
val jsonUrl = withListFreshness("${url.removeSuffix("/")}/json")
val fromJson = fetchUrl(jsonUrl)?.let { payload ->
parseMdblistJson(payload)
} ?: emptyList()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.arflix.tv.data.repository

import com.arflix.tv.data.api.TraktApi
import com.arflix.tv.data.api.TraktIds
import com.arflix.tv.data.api.TraktPublicListItem
import com.arflix.tv.data.api.TraktShowInfo
import com.arflix.tv.data.model.CatalogConfig
import com.arflix.tv.data.model.CatalogSourceType
import com.arflix.tv.data.model.MediaItem
import com.arflix.tv.data.model.MediaType
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.CopyOnWriteArrayList

class ListFreshnessTest {
@Test fun `marker holds for one window and moves with the next`() {
val start = 7 * LIST_REFRESH_MS
assertEquals(listFreshnessMarker(start), listFreshnessMarker(start + LIST_REFRESH_MS - 1))
assertEquals(listFreshnessMarker(start) + 1, listFreshnessMarker(start + LIST_REFRESH_MS))
}

@Test fun `marker is appended as its own query parameter`() {
val now = 3 * LIST_REFRESH_MS
assertEquals("https://example.test/list/json?_=3", withListFreshness("https://example.test/list/json", now))
assertEquals("https://example.test/list/json?a=1&_=3", withListFreshness("https://example.test/list/json?a=1", now))
}

@Test fun `trakt list rows ask for the current window`() = runTest {
val trakt = mockk<TraktApi>()
coEvery { trakt.getUserListItems(any(), any(), any(), any(), "movies", any(), any(), any(), any()) } returns emptyList()
coEvery { trakt.getUserListItems(any(), any(), any(), any(), "shows", any(), 1, any(), any()) } returns listOf(
TraktPublicListItem(type = "show", show = TraktShowInfo("Some Show", 2020, TraktIds(tmdb = 501)))
)
coEvery { trakt.getUserListItems(any(), any(), any(), any(), "shows", any(), 2, any(), any()) } returns emptyList()
val catalog = CatalogConfig(
id = "custom_list", title = "List", sourceType = CatalogSourceType.TRAKT,
sourceUrl = "https://trakt.tv/users/someone/lists/some-list",
sourceRef = "trakt_user:someone:some-list"
)

val page = repository(trakt = trakt).loadCustomCatalogPage(catalog, 0, 20)

assertEquals(listOf(501), page.items.map { it.id })
coVerify { trakt.getUserListItems(any(), any(), "someone", "some-list", "shows", any(), 1, any(), match { it != null }) }
coVerify(exactly = 0) { trakt.getUserListItems(any(), any(), any(), any(), any(), any(), any(), any(), null) }
}

@Test fun `mdblist rows ask for the current window`() = runTest {
val requested = CopyOnWriteArrayList<String>()
val client = OkHttpClient.Builder().addInterceptor { chain ->
requested += chain.request().url.toString()
Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(200).message("OK")
.body("""[{"id":501,"mediatype":"show"}]""".toResponseBody("application/json".toMediaType()))
.build()
}.build()
val catalog = CatalogConfig(
id = "custom_md", title = "List", sourceType = CatalogSourceType.MDBLIST,
sourceUrl = "https://mdblist.com/lists/someone/some-list",
sourceRef = "mdblist:https://mdblist.com/lists/someone/some-list"
)

val page = repository(client = client).loadCustomCatalogPage(catalog, 0, 20)

assertEquals(listOf(501), page.items.map { it.id })
assertTrue(requested.toString(), requested.any {
it.matches(Regex("""https://mdblist\.com/lists/someone/some-list/json\?_=\d+"""))
})
}

private fun repository(
trakt: TraktApi = mockk(relaxed = true),
client: OkHttpClient = mockk(relaxed = true)
) = spyk(MediaRepository(
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trakt,
client, mockk(relaxed = true), mockk(relaxed = true)
)).also { repository ->
coEvery { repository.getMovieDetails(any()) } answers { MediaItem(firstArg(), "Movie ${firstArg<Int>()}") }
coEvery { repository.getTvDetails(any()) } answers { MediaItem(firstArg(), "Series ${firstArg<Int>()}", mediaType = MediaType.TV) }
}
}
Loading