diff --git a/app/src/androidTest/kotlin/com/arflix/tv/data/repository/ReleaseCalendarLiveDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/data/repository/ReleaseCalendarLiveDeviceTest.kt new file mode 100644 index 000000000..fd358efd4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/arflix/tv/data/repository/ReleaseCalendarLiveDeviceTest.kt @@ -0,0 +1,74 @@ +package com.arflix.tv.data.repository + +import android.os.Bundle +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.arflix.tv.di.RepositoryAccessEntryPoint +import dagger.hilt.android.EntryPointAccessors +import java.time.YearMonth +import java.time.ZoneId +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Opt-in account benchmark: only disposable calendar metadata is cleared, never account/settings. */ +@RunWith(AndroidJUnit4::class) +class ReleaseCalendarLiveDeviceTest { + @Test fun configuredWatchlistsLoadColdAndWarmWithoutLosingReleases() = runBlocking { + assumeTrue(InstrumentationRegistry.getArguments().getString("calendarLive") == "true") + val instrumentation = InstrumentationRegistry.getInstrumentation() + val access = EntryPointAccessors.fromApplication(instrumentation.targetContext, RepositoryAccessEntryPoint::class.java) + val repository = access.releaseCalendarRepository() + val profile = access.profileManager().getProfileIdSync() + val month = YearMonth.now() + val timezone = ZoneId.systemDefault() + repository.invalidateMetadata() + val start = SystemClock.elapsedRealtime() + var firstReleaseMs: Long? = null + var lastReleaseMs = 0L + val discovered = mutableSetOf() + var lists = CalendarWatchlists(profile, emptyMap()) + val cold = repository.loadCalendar(profile, month, timezone, "NL") { progress -> + lists = progress.watchlists + if (firstReleaseMs == null && progress.month.entries.isNotEmpty()) { + firstReleaseMs = SystemClock.elapsedRealtime() - start + } + val ids = progress.month.entries.map { it.id }.toSet() + if (ids.any { it !in discovered }) lastReleaseMs = SystemClock.elapsedRealtime() - start + discovered.addAll(ids) + } + val coldMs = SystemClock.elapsedRealtime() - start + assertTrue("This live test requires configured watchlist titles", mergeCalendarWatchlists(lists.items).isNotEmpty()) + assertTrue("The configured account must have releases this month", cold.entries.isNotEmpty()) + val warmStart = SystemClock.elapsedRealtime() + var warmFirstMs: Long? = null + val warm = repository.loadMonth(lists, month, timezone, "NL") { progress -> + if (warmFirstMs == null && progress.entries.isNotEmpty()) warmFirstMs = SystemClock.elapsedRealtime() - warmStart + } + val warmMs = SystemClock.elapsedRealtime() - warmStart + assertEquals("Warm loading must not omit releases", cold.entries.map { it.id }.toSet(), warm.entries.map { it.id }.toSet()) + instrumentation.sendStatus(0, Bundle().apply { + putString("stream", "CALENDAR_LIVE titles=${mergeCalendarWatchlists(lists.items).size} releases=${cold.entries.size} " + + "coldFirst=${firstReleaseMs}ms coldLastRelease=${lastReleaseMs}ms coldComplete=${coldMs}ms warmFirst=${warmFirstMs}ms warmComplete=${warmMs}ms " + + "providerWarnings=${lists.warnings.size} metadataWarnings=${cold.warnings.size - lists.warnings.size}\n") + }) + val failedTitles = mergeCalendarWatchlists(lists.items).filter { title -> + cold.warnings.any { it == "Release dates for ${title.media.title} are unavailable." } + } + for (title in failedTitles.take(2)) { + val status = try { + if (title.media.mediaType == com.arflix.tv.data.model.MediaType.TV) { + access.tmdbApi().getTvDetails(title.media.id, com.arflix.tv.util.Constants.TMDB_API_KEY) + } else access.tmdbApi().getMovieDetails(title.media.id, com.arflix.tv.util.Constants.TMDB_API_KEY) + "recovered" + } catch (error: retrofit2.HttpException) { "HTTP_${error.code()}" } + catch (_: Exception) { "network_failure" } + instrumentation.sendStatus(0, Bundle().apply { + putString("stream", "CALENDAR_METADATA_SAMPLE type=${title.media.mediaType} status=$status\n") + }) + } + } +} diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/watchlist/LibraryCalendarDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/watchlist/LibraryCalendarDeviceTest.kt index 07d975816..2b2931e35 100644 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/watchlist/LibraryCalendarDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/watchlist/LibraryCalendarDeviceTest.kt @@ -24,7 +24,6 @@ import com.arflix.tv.data.model.CalendarRelease import com.arflix.tv.data.model.CalendarReleaseKind import com.arflix.tv.data.model.ReleaseCalendarSource import com.arflix.tv.ui.components.AppTopBarContentTopInset -import com.arflix.tv.ui.components.AppTopBarHeight import com.arflix.tv.ui.components.SidebarItem import com.arflix.tv.ui.screens.watchlist.calendar.ReleaseCalendarUiState import com.arflix.tv.ui.theme.ArvioTvTheme @@ -133,13 +132,27 @@ class LibraryCalendarDeviceTest { show(selectCalendar = false) val tags = listOf("app-topbar", "topbar-profile") + SidebarItem.entries.map { "topbar-item-${it.name}" } val normalBounds = tags.map { compose.onNodeWithTag(it).getUnclippedBoundsInRoot() } + val normalTabs = compose.onNodeWithTag("library-section-tabs").getUnclippedBoundsInRoot() + val normalCalendarTab = compose.onNodeWithText("Calendar").getUnclippedBoundsInRoot() + val normalTabHeight = normalCalendarTab.bottom - normalCalendarTab.top + LibrarySection.entries.forEach { section -> + compose.onNodeWithText(section.label).performClick() + compose.waitForIdle() + assertEquals("All Library sections keep the same compact tab spacing", normalTabs, + compose.onNodeWithTag("library-section-tabs").getUnclippedBoundsInRoot()) + } compose.onNodeWithText("Calendar").performClick() compose.waitForIdle() assertEquals("Switching Library tabs must not resize or move the shared topbar", normalBounds, tags.map { compose.onNodeWithTag(it).getUnclippedBoundsInRoot() }) compose.onNodeWithTag("app-topbar").assertHeightIsEqualTo(AppTopBarContentTopInset) - compose.onNodeWithTag("library-section-tabs").assertTopPositionInRootIsEqualTo(AppTopBarHeight - 10.dp) + compose.onNodeWithTag("library-section-tabs").assertTopPositionInRootIsEqualTo(LibraryTabsTopInset) val tabs = compose.onNodeWithTag("library-section-tabs").getUnclippedBoundsInRoot() + val controlsBottom = tags.drop(1).maxOf { compose.onNodeWithTag(it).getUnclippedBoundsInRoot().bottom } + assertTrue("Library tabs sit just below the visible topbar controls", tabs.top - controlsBottom <= 12.dp) + assertEquals("Calendar uses the same tab position and height as the other Library pages", normalTabs, tabs) + val calendarTab = compose.onNodeWithText("Calendar").getUnclippedBoundsInRoot() + assertEquals(normalTabHeight, calendarTab.bottom - calendarTab.top) tags.drop(1).forEach { tag -> assertTrue("Calendar content must not overlap a topbar control", compose.onNodeWithTag(tag).getUnclippedBoundsInRoot().bottom <= tabs.top) @@ -191,7 +204,7 @@ class LibraryCalendarDeviceTest { compose.runOnIdle { assertEquals(MediaType.TV to 100088, opened) } } - @Test fun sixWeekMonthKeepsReleaseCountsVisibleAndFocusCrossesMonthEdges() { + @Test fun sixWeekMonthKeepsReleaseCountsVisibleAndFocusStaysWithinMonth() { val selected = referenceDate.plusMonths(1) show(selectedDate = selected, configure = ::novemberFixture) compose.onAllNodes(hasAnyAncestor(hasTestTag("calendar-month-grid")) and hasClickAction()).assertCountEquals(42) @@ -207,12 +220,31 @@ class LibraryCalendarDeviceTest { val first = LocalDate.of(2026, 11, 1) focusDay(first) - key(Key.DirectionLeft) - assertDate(first.minusDays(1)) - compose.onNodeWithTag("calendar-day-2026-10-31").assertIsFocused() - key(Key.DirectionRight) + repeat(4) { key(Key.DirectionLeft) } assertDate(first) compose.onNodeWithTag("calendar-day-$first").assertIsFocused() + key(Key.DirectionUp) + compose.onNodeWithTag("calendar-previous-month").assertIsFocused() + key(Key.DirectionDown) + compose.onNodeWithTag("calendar-day-$first").assertIsFocused() + focusDay(first.withDayOfMonth(30)) + repeat(4) { key(Key.DirectionRight) } + assertDate(first.withDayOfMonth(30)) + compose.onNodeWithText("November 2026").assertIsDisplayed() + } + + @Test fun nativeRemoteRepeatsCannotJumpMonthsOrActivateReleases() { + show(selectedDate = LocalDate.of(2026, 10, 1)) + focusDay(LocalDate.of(2026, 10, 1)) + val instrumentation = InstrumentationRegistry.getInstrumentation() + repeat(35) { instrumentation.sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) } + compose.waitForIdle() + assertDate(LocalDate.of(2026, 10, 31)) + repeat(35) { instrumentation.sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_DPAD_LEFT) } + compose.waitForIdle() + assertDate(LocalDate.of(2026, 10, 1)) + compose.onNodeWithTag("calendar-day-2026-10-01").assertIsFocused() + compose.runOnIdle { assertEquals(null, opened) } } @Test fun emptyDayRemoteShortcutUsesTheSelectedSourceAndReturnsToItsDay() { diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/watchlist/ReleaseCalendarPreloadDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/watchlist/ReleaseCalendarPreloadDeviceTest.kt new file mode 100644 index 000000000..6d6eeb0e0 --- /dev/null +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/watchlist/ReleaseCalendarPreloadDeviceTest.kt @@ -0,0 +1,97 @@ +package com.arflix.tv.ui.screens.watchlist + +import androidx.lifecycle.ViewModelStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.arflix.tv.data.model.* +import com.arflix.tv.data.repository.* +import com.arflix.tv.ui.screens.watchlist.calendar.ReleaseCalendarViewModel +import io.mockk.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.time.YearMonth + +/** Controlled provider delays exercise the real ViewModel without changing account data. */ +@RunWith(AndroidJUnit4::class) +class ReleaseCalendarPreloadDeviceTest { + private suspend fun fixture(block: suspend (ReleaseCalendarViewModel, ReleaseCalendarRepository) -> Unit) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val profiles = mockk() + every { profiles.activeProfileId } returns MutableStateFlow("calendar-preload-fixture") + every { profiles.getProfileIdSync() } returns "calendar-preload-fixture" + val watchlist = mockk() + val items = listOf(MediaItem(1, "Fixture movie")) + every { watchlist.watchlistItems } returns MutableStateFlow(items) + coEvery { watchlist.getLocalWatchlistItems() } returns items + val repository = mockk() + coEvery { repository.loadMonth(any(), any(), any(), any(), any(), any(), any()) } returns CalendarMonthResult(emptyList(), emptyList()) + val directory = java.io.File(context.cacheDir, "calendar-preload-test-${System.nanoTime()}") + val store = ViewModelStore() + val viewModel = withContext(Dispatchers.Main) { + ReleaseCalendarViewModel(context, repository, watchlist, profiles, ReleaseCalendarCache(directory)) + .also { store.put("calendar", it) } + } + try { block(viewModel, repository) } + finally { + withContext(Dispatchers.Main) { store.clear() } + directory.deleteRecursively() + } + } + + private fun progress(): CalendarLoadProgress { + val month = YearMonth.now() + val media = MediaItem(1, "Fixture movie") + val entry = CalendarRelease("fixture", media, month.atDay(4), kind = CalendarReleaseKind.MOVIE, sourceIds = setOf("arvio")) + return CalendarLoadProgress(CalendarWatchlists("calendar-preload-fixture", mapOf(ReleaseCalendarSource.ARVIO to listOf(media))), + CalendarMonthResult(listOf(entry), emptyList(), setOf(MediaType.MOVIE to 1)), true, setOf(MediaType.MOVIE to 1)) + } + + @Test fun doesNotFetchBeforeVisibleOrIdleAndForegroundPromotionSurvivesIdleCancellation() = runBlocking { + fixture { viewModel, repository -> + val started = CompletableDeferred() + val finish = CompletableDeferred() + coEvery { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) } coAnswers { + started.complete(Unit) + arg Unit>(6)(progress()) + finish.await() + progress().month + } + delay(300) + coVerify(exactly = 0) { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) } + val preload = launch(Dispatchers.Main) { viewModel.preloadIdle() } + withTimeout(10_000) { started.await() } + withContext(Dispatchers.Main) { viewModel.onVisible() } + preload.cancelAndJoin() + finish.complete(Unit) + val state = withTimeout(10_000) { viewModel.uiState.first { !it.isLoading } } + assertEquals(listOf("fixture"), state.entries.map { it.id }) + coVerify(exactly = 1) { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) } + } + } + + @Test fun cancelledPreloadResumesOnEntryEvenWithAFreshCompletedProviderSnapshot() = runBlocking { + fixture { viewModel, repository -> + val started = CompletableDeferred() + val cancelled = CompletableDeferred() + coEvery { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) } coAnswers { + arg Unit>(6)(progress().copy(month = CalendarMonthResult(emptyList(), emptyList()))) + started.complete(Unit) + try { awaitCancellation() } finally { cancelled.complete(Unit) } + } + coEvery { repository.loadMonth(any(), any(), any(), any(), any(), false, any()) } returns progress().month + val preload = launch(Dispatchers.Main) { viewModel.preloadIdle() } + withTimeout(10_000) { started.await() } + preload.cancelAndJoin() + withTimeout(10_000) { cancelled.await() } + assertTrue(viewModel.uiState.value.isLoading) + withContext(Dispatchers.Main) { viewModel.onVisible() } + val resumed = withTimeout(10_000) { viewModel.uiState.first { !it.isLoading } } + assertEquals(listOf("fixture"), resumed.entries.map { it.id }) + coVerify(exactly = 1) { repository.loadMonth(any(), any(), any(), any(), any(), false, any()) } + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt index 69d6e0527..0909abc03 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt @@ -1,6 +1,7 @@ package com.arflix.tv.data.api import com.google.gson.annotations.SerializedName +import com.google.gson.JsonObject import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query @@ -11,6 +12,14 @@ import retrofit2.http.QueryMap */ interface TmdbApi { + @GET("tv/{tv_id}") + suspend fun getTvSeasons( + @Path("tv_id") tvId: Int, + @Query("api_key") apiKey: String, + @Query("append_to_response") appendToResponse: String, + @Query("language") language: String? = null + ): JsonObject + @GET("trending/movie/day") suspend fun getTrendingMovies( @Query("api_key") apiKey: String, diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCache.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCache.kt index f424a96fc..a1948a0a0 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCache.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCache.kt @@ -28,6 +28,9 @@ internal data class CalendarMonthPreview( val refreshedAt: Long = System.currentTimeMillis() ) +internal data class CalendarMetadataRecord(val value: T, val writtenAt: Long) +internal data class CalendarTitleProjection(val entries: List, val expiresAt: Long) + /** Disposable, app-private cache; never backed up or used as an authoritative watchlist. */ @Singleton class ReleaseCalendarCache internal constructor( @@ -42,8 +45,18 @@ class ReleaseCalendarCache internal constructor( // A wrapper permits both object responses and primitive/list metadata without reflection. internal suspend fun readValue(key: String, type: Type): T? = withContext(Dispatchers.IO) { - read("metadata:$key", METADATA_TTL)?.let { - try { gson.fromJson(it.get("value"), type) } catch (_: Exception) { null } + read("metadata:$key", CALENDAR_ACTIVE_TTL)?.let { + try { gson.fromJson(it.json.get("value"), type) } catch (_: Exception) { null } + } + } + + internal suspend fun readMetadata(key: String, type: Type): CalendarMetadataRecord? = withContext(Dispatchers.IO) { + read("metadata:$key", CALENDAR_HISTORY_TTL)?.let { + try { + val value = gson.fromJson(it.json.get("value"), type) ?: return@let null + CalendarMetadataRecord(value, it.writtenAt) + } + catch (_: Exception) { null } } } @@ -52,11 +65,29 @@ class ReleaseCalendarCache internal constructor( } internal suspend fun readMonth(key: String): CalendarMonthPreview? = withContext(Dispatchers.IO) { - read("month:$key", MONTH_TTL)?.let { json -> + read("month:$key", MONTH_TTL)?.let { decodePreview(it.json) } + } + + internal suspend fun readTitle(key: String): CalendarTitleProjection? = withContext(Dispatchers.IO) { + read("projection:$key", CALENDAR_HISTORY_TTL)?.let { stored -> + try { + val expiry = stored.json.get("expiresAt")?.asLong ?: return@let null + if (now() >= expiry) return@let null + decodePreview(stored.json)?.let { CalendarTitleProjection(it.entries, expiry) } + } catch (_: Exception) { null } + } + } + + internal suspend fun writeTitle(key: String, projection: CalendarTitleProjection): Unit = withContext(Dispatchers.IO) { + write("projection:$key", encodePreview(CalendarMonthPreview(projection.entries, emptyMap(), now())).apply { + addProperty("expiresAt", projection.expiresAt) + }) + } + + private fun decodePreview(json: JsonObject): CalendarMonthPreview? = try { val refreshedAt = json.get("refreshedAt").asLong - if (now() - refreshedAt !in 0 until MONTH_TTL) return@let null - CalendarMonthPreview(json.getAsJsonArray("entries").map { element -> + if (now() - refreshedAt !in 0 until MONTH_TTL) null else CalendarMonthPreview(json.getAsJsonArray("entries").map { element -> val item = element.asJsonObject CalendarRelease( id = item.get("id").asString, @@ -73,11 +104,12 @@ class ReleaseCalendarCache internal constructor( ) }, json.getAsJsonObject("counts").entrySet().associate { it.key to it.value.asInt }, refreshedAt) } catch (_: Exception) { null } - } - } internal suspend fun writeMonth(key: String, preview: CalendarMonthPreview): Unit = withContext(Dispatchers.IO) { - write("month:$key", JsonObject().apply { + write("month:$key", encodePreview(preview)) + } + + private fun encodePreview(preview: CalendarMonthPreview) = JsonObject().apply { addProperty("refreshedAt", preview.refreshedAt) add("counts", JsonObject().apply { preview.sourceCounts.forEach { (id, count) -> addProperty(id, count) } }) add("entries", JsonArray().apply { preview.entries.forEach { entry -> @@ -95,22 +127,23 @@ class ReleaseCalendarCache internal constructor( entry.region?.let { addProperty("region", it) } }) } }) - }) - } + } internal suspend fun clearMetadata(): Unit = withContext(Dispatchers.IO) { fileMutex.withLock { - directory.listFiles()?.filter { it.name.startsWith("metadata-") }?.forEach { it.delete() } + directory.listFiles()?.filter { it.name.startsWith("metadata-") || it.name.startsWith("projection-") }?.forEach { it.delete() } } } - private suspend fun read(key: String, ttl: Long): JsonObject? = withContext(Dispatchers.IO) { + private data class Stored(val json: JsonObject, val writtenAt: Long) + + private suspend fun read(key: String, ttl: Long): Stored? = withContext(Dispatchers.IO) { fileMutex.withLock { try { val file = file(key) val age = now() - file.lastModified() if (!file.isFile || age !in 0 until ttl) return@withLock null - JsonParser.parseString(file.readText()).asJsonObject + Stored(JsonParser.parseString(file.readText()).asJsonObject, file.lastModified()) } catch (error: CancellationException) { throw error } catch (_: Exception) { null } } @@ -129,8 +162,12 @@ class ReleaseCalendarCache internal constructor( target.setLastModified(now()) if (++writesSinceTrim >= 32) { writesSinceTrim = 0 + var bytes = 0L directory.listFiles()?.filter { it.extension == "json" }?.sortedByDescending { it.lastModified() } - ?.drop(768)?.forEach { it.delete() } + ?.forEachIndexed { index, file -> + bytes += file.length() + if (index >= 4096 || bytes > 48 * 1024 * 1024L) file.delete() + } } } catch (error: CancellationException) { throw error } catch (_: Exception) { /* Cache failures must never fail a calendar load. */ } @@ -140,7 +177,6 @@ class ReleaseCalendarCache internal constructor( private fun file(key: String) = File(directory, "${key.substringBefore(':')}-${calendarCacheDigest(key)}.json") private companion object { - const val METADATA_TTL = 30 * 60_000L const val MONTH_TTL = 24 * 60 * 60_000L } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCachePolicy.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCachePolicy.kt new file mode 100644 index 000000000..d5490a9b2 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCachePolicy.kt @@ -0,0 +1,24 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.api.TmdbMovieDetails +import com.arflix.tv.data.api.TmdbSeasonDetails +import com.arflix.tv.data.api.TmdbTvDetails +import java.time.Instant +import java.time.ZoneOffset + +internal const val CALENDAR_ACTIVE_TTL = 30 * 60_000L +internal const val CALENDAR_HISTORY_TTL = 24 * 60 * 60_000L + +/** Unknown or future dates must stay on the short refresh interval. */ +internal fun calendarMetadataTtl(value: Any, now: Long): Long { + val cutoff = Instant.ofEpochMilli(now).atZone(ZoneOffset.UTC).toLocalDate().minusDays(30) + fun historical(date: String?) = calendarDate(date)?.let { it < cutoff } == true + val stable = when (value) { + is TmdbMovieDetails -> historical(value.releaseDate) && + value.releaseDates?.results.orEmpty().flatMap { it.releaseDates }.all { historical(it.releaseDate?.take(10)) } + is TmdbTvDetails -> value.status in setOf("Ended", "Canceled") && historical(value.lastEpisodeToAir?.airDate) + is TmdbSeasonDetails -> value.episodes.isNotEmpty() && value.episodes.all { historical(it.airDate) } + else -> false + } + return if (stable) CALENDAR_HISTORY_TTL else CALENDAR_ACTIVE_TTL +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepository.kt index 002da4000..776a599ad 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepository.kt @@ -14,13 +14,19 @@ import com.arflix.tv.data.model.ReleaseCalendarSource import com.arflix.tv.data.repository.simkl.SimklAuthManager import com.arflix.tv.data.repository.simkl.SimklSyncService import com.arflix.tv.util.Constants +import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.io.IOException import java.time.YearMonth import java.time.ZoneId +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -33,6 +39,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.withContext +import retrofit2.HttpException internal data class CalendarWatchlists( val profileId: String, @@ -93,6 +101,7 @@ class ReleaseCalendarRepository @Inject constructor( private val diskCache: ReleaseCalendarCache ) { private val permits = Semaphore(5) + private val backgroundTitlePermits = Semaphore(5) // Old seasons must not fill every metadata slot before a newly arrived title // can publish its next episode. Season reads still count toward the total five. private val seasonPermits = Semaphore(2) @@ -101,8 +110,20 @@ class ReleaseCalendarRepository @Inject constructor( private val artworkPermits = Semaphore(2) private data class Cached(val value: Any, val at: Long) private val metadataCache = linkedMapOf() + private val titleCache = linkedMapOf() + private val cacheGeneration = AtomicLong() + private val missingMetadata = linkedMapOf() + internal var clock: () -> Long = System::currentTimeMillis + + private class TitleRead(val cacheOnly: Boolean) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + val expiresAt = AtomicLong(Long.MAX_VALUE) + fun include(expiry: Long) { expiresAt.updateAndGet { minOf(it, expiry) } } + } // Bounded striped locks coalesce identical requests, including a restarted/adjacent month load. private val metadataLocks = Array(3) { Array(64) { Mutex() } } + private val seasonBatchLocks = Array(64) { Mutex() } + private val gson = Gson() internal suspend fun loadWatchlists( profileId: String, @@ -190,12 +211,12 @@ class ReleaseCalendarRepository @Inject constructor( progressMutex.withLock { ensureProfile(profileId) watchlists = snapshot - mergeCalendarWatchlists(snapshot.items).forEach { title -> + prioritizeTitles(mergeCalendarWatchlists(snapshot.items), month).forEach { title -> val key = title.media.mediaType to title.media.id val isNew = key !in titles titles[key] = title if (isNew) jobs += launch { - val result = titleReleases(title, month, timezone, region, language) { result -> + val result = prioritizedTitleReleases(title, month, timezone, region, language) { result -> progressMutex.withLock { results[key] = result publish() @@ -226,6 +247,7 @@ class ReleaseCalendarRepository @Inject constructor( timezone: ZoneId, region: String, language: String = "en-US", + cacheOnly: Boolean = false, onProgress: suspend (CalendarMonthResult) -> Unit = {} ): CalendarMonthResult = coroutineScope { ensureProfile(watchlists.profileId) @@ -238,8 +260,10 @@ class ReleaseCalendarRepository @Inject constructor( onProgress(combineResults(partial.values, watchlists.warnings)) } } - val results = mergeCalendarWatchlists(watchlists.items).map { title -> async { - val result = titleReleases(title, month, timezone, region, language) { publish(title, it) } + val results = prioritizeTitles(mergeCalendarWatchlists(watchlists.items), month).map { title -> async { + val result = withContext(TitleRead(cacheOnly)) { + prioritizedTitleReleases(title, month, timezone, region, language) { publish(title, it) } + } result.copy(completedTitles = if (result.warnings.isEmpty()) setOf(title.media.mediaType to title.media.id) else emptySet()) .also { publish(title, it) } } }.awaitAll() @@ -247,6 +271,15 @@ class ReleaseCalendarRepository @Inject constructor( combineResults(results, watchlists.warnings) } + private suspend fun prioritizedTitleReleases( + title: CalendarWatchlistTitle, month: YearMonth, timezone: ZoneId, region: String, language: String, + onProgress: suspend (CalendarMonthResult) -> Unit + ): CalendarMonthResult = if (titlePriority(title, month) == 2) { + // Do not queue hundreds of old movies ahead of an active show arriving + // from another provider. All five network slots can still be used. + backgroundTitlePermits.withPermit { titleReleases(title, month, timezone, region, language, onProgress) } + } else titleReleases(title, month, timezone, region, language, onProgress) + private suspend fun titleReleases( title: CalendarWatchlistTitle, month: YearMonth, @@ -254,6 +287,40 @@ class ReleaseCalendarRepository @Inject constructor( region: String, language: String, onProgress: suspend (CalendarMonthResult) -> Unit + ): CalendarMonthResult { + val key = "${profileManager.getProfileIdSync()}|${title.media.mediaType}:${title.media.id}|$month|${timezone.id}|$region|$language|${title.media.traktId}" + val generation = cacheGeneration.get() + val saved = synchronized(titleCache) { titleCache[key]?.takeIf { clock() < it.expiresAt } } + ?: diskCache.readTitle(key)?.also { rememberTitle(key, it) } + if (saved != null) { + return CalendarMonthResult(saved.entries.map { it.copy(sourceIds = title.sourceIds) }, emptyList()) + .also { onProgress(it) } + } + val read = coroutineContext[TitleRead] ?: TitleRead(cacheOnly = false) + return withContext(read) { + val result = fetchTitleReleases(title, month, timezone, region, language, onProgress) + // Empty, successfully verified months are reusable too. Errors are never + // recorded as empty schedules, and source membership always comes from today's lists. + if (!read.cacheOnly && result.warnings.isEmpty() && read.expiresAt.get() > clock() && read.expiresAt.get() != Long.MAX_VALUE && + generation == cacheGeneration.get()) { + val projection = CalendarTitleProjection(result.entries, read.expiresAt.get()) + rememberTitle(key, projection) + diskCache.writeTitle(key, projection) + } + result + } + } + + private fun rememberTitle(key: String, projection: CalendarTitleProjection) { + synchronized(titleCache) { + titleCache[key] = projection + while (titleCache.size > 2048) titleCache.remove(titleCache.keys.first()) + } + } + + private suspend fun fetchTitleReleases( + title: CalendarWatchlistTitle, month: YearMonth, timezone: ZoneId, region: String, language: String, + onProgress: suspend (CalendarMonthResult) -> Unit ): CalendarMonthResult { var lastPublished: CalendarMonthResult? = null suspend fun publish(result: CalendarMonthResult) { @@ -273,7 +340,7 @@ class ReleaseCalendarRepository @Inject constructor( CalendarMonthResult(calendarMovieReleases(enriched, details, month, region), emptyList()) } publish(result) - val logo = if (result.entries.isNotEmpty()) { + val logo = if (result.entries.isNotEmpty() && coroutineContext[TitleRead]?.cacheOnly != true) { try { // Include queue time in the optional-artwork budget. withTimeoutOrNull(3_000) { @@ -285,13 +352,19 @@ class ReleaseCalendarRepository @Inject constructor( null } } else null + if (logo == null && result.entries.isNotEmpty()) { + // A failed optional artwork read can recover quickly without redoing + // metadata for every title whenever the Calendar is reopened. + coroutineContext[TitleRead]?.include(clock() + 2 * 60_000L) + } result.copy(entries = result.entries.map { it.copy(logoUrl = logo) }).also { publish(it) } } catch (error: CancellationException) { throw error } catch (_: Exception) { // A later enrichment failure must not remove already confirmed dates. - val fallback = lastPublished ?: CalendarMonthResult(emptyList(), - listOf("Release dates for ${title.media.title} are unavailable.")) + val warning = "Release dates for ${title.media.title} are unavailable." + val fallback = lastPublished?.let { it.copy(warnings = it.warnings + warning) } + ?: CalendarMonthResult(emptyList(), listOf(warning)) fallback.also { publish(it) } } } @@ -338,25 +411,13 @@ class ReleaseCalendarRepository @Inject constructor( } // TV details can already contain the next release; render it while seasons fill in. publishDates() - seasons.map { season -> async { - val (episodes, failed) = try { - val data = cached("season:${title.media.id}:${season.seasonNumber}:$language", seasonPermits) { - permits.withPermit { - tmdbApi.getTvSeason(title.media.id, season.seasonNumber, Constants.TMDB_API_KEY, language = language) - } - } - data.episodes to false - } catch (error: CancellationException) { - throw error - } catch (_: Exception) { - emptyList() to true - } + readSeasons(title.media.id, seasons.map { it.seasonNumber }, language) { data -> episodeMutex.withLock { - episodes.forEach { knownEpisodes[it.seasonNumber to it.episodeNumber] = it } - incompleteSeasons = incompleteSeasons || failed + data?.episodes.orEmpty().forEach { knownEpisodes[it.seasonNumber to it.episodeNumber] = it } + incompleteSeasons = incompleteSeasons || data == null publishDates() } - } }.awaitAll() + } val episodes = knownEpisodes.values.toList() val relevantSeasons = episodes.filter { calendarDate(it.airDate)?.let { date -> date >= first && date <= last } == true } .map { it.seasonNumber }.distinct() @@ -367,6 +428,77 @@ class ReleaseCalendarRepository @Inject constructor( ) } + /** Append up to twenty seasons per HTTP request, without excluding old seasons or specials. */ + private suspend fun readSeasons( + tvId: Int, + seasons: List, + language: String, + onSeason: suspend (TmdbSeasonDetails?) -> Unit + ) { + val lock = seasonBatchLocks[("$tvId:$language".hashCode() and Int.MAX_VALUE) % seasonBatchLocks.size] + lock.withLock { + val missing = mutableListOf() + for (season in seasons) { + val saved = cachedValue("season:$tvId:$season:$language") + if (saved != null) onSeason(saved) else missing += season + } + for (batch in missing.chunked(20)) { + if (coroutineContext[TitleRead]?.cacheOnly == true) { + batch.forEach { onSeason(null) } + continue + } + val appended = if (batch.size > 1) try { + val response = seasonPermits.withPermit { + permits.withPermit { + withTimeoutOrNull(20_000) { + tmdbApi.getTvSeasons(tvId, Constants.TMDB_API_KEY, + batch.joinToString(",") { "season/$it" }, language) + } ?: throw IOException("Season batch timed out") + } + } + withContext(Dispatchers.Default) { + batch.mapNotNull { season -> + runCatching { + val value = response.get("season/$season")?.takeIf { it.isJsonObject }?.asJsonObject + if (value?.get("episodes")?.isJsonArray != true || value.get("season_number")?.asInt != season) null + else season to gson.fromJson(value, TmdbSeasonDetails::class.java) + }.getOrNull() + }.toMap() + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + emptyMap() + } else emptyMap() + for ((season, data) in appended) { + val key = "season:$tvId:$season:$language" + rememberMetadata(key, data) + includeMetadata(data, clock()) + onSeason(data) + diskCache.writeValue(key, data) + } + // A missing/malformed appended block falls back independently; other + // seasons remain usable and the next month reuses their normal cache keys. + coroutineScope { + batch.filter { it !in appended }.map { season -> async { + val data = try { + cached("season:$tvId:$season:$language", seasonPermits) { + permits.withPermit { + tmdbApi.getTvSeason(tvId, season, Constants.TMDB_API_KEY, language = language) + } + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } + onSeason(data) + } }.awaitAll() + } + } + } + } + private suspend fun authoritativeTimes(media: MediaItem, seasons: List): List = coroutineScope { if (seasons.isEmpty() || Constants.TRAKT_CLIENT_ID.isBlank()) return@coroutineScope emptyList() val found = mutableListOf() @@ -406,19 +538,66 @@ class ReleaseCalendarRepository @Inject constructor( backdrop = normalizeWatchlistArtworkUrl(backdrop, true) ?: media.backdrop ) + private fun prioritizeTitles(titles: List, month: YearMonth): List = + titles.sortedBy { titlePriority(it, month) } + + private fun titlePriority(title: CalendarWatchlistTitle, month: YearMonth): Int { + val media = title.media + val release = calendarDate(media.releaseDate) + return when { + media.mediaType == MediaType.TV && media.status !in setOf("Ended", "Canceled") -> 0 + release != null && release >= month.atDay(1).minusMonths(2) && release <= month.atEndOfMonth() -> 0 + media.mediaType == MediaType.TV -> 1 + else -> 2 + } + } + private fun ensureProfile(profileId: String) { if (profileManager.getProfileIdSync() != profileId) throw CancellationException("Calendar profile changed") } internal suspend fun invalidateMetadata() { - synchronized(metadataCache) { metadataCache.clear() } + cacheGeneration.incrementAndGet() + synchronized(metadataCache) { metadataCache.clear(); missingMetadata.clear() } + synchronized(titleCache) { titleCache.clear() } diskCache.clearMetadata() } + private fun rememberMetadata(key: String, value: Any, writtenAt: Long = clock()) { + synchronized(metadataCache) { + metadataCache[key] = Cached(value, writtenAt) + while (metadataCache.size > 2048) metadataCache.remove(metadataCache.keys.first()) + } + } + + private suspend fun includeMetadata(value: Any, writtenAt: Long) { + coroutineContext[TitleRead]?.include(writtenAt + calendarMetadataTtl(value, clock())) + } + + @Suppress("UNCHECKED_CAST") + private suspend inline fun cachedValue(key: String): T? { + val memory = synchronized(metadataCache) { + metadataCache[key]?.takeIf { clock() - it.at in 0 until calendarMetadataTtl(it.value, clock()) } + } + if (memory != null) { + includeMetadata(memory.value, memory.at) + return memory.value as T + } + val disk = diskCache.readMetadata(key, object : TypeToken() {}.type) ?: return null + if (clock() - disk.writtenAt !in 0 until calendarMetadataTtl(disk.value, clock())) return null + rememberMetadata(key, disk.value, disk.writtenAt) + includeMetadata(disk.value, disk.writtenAt) + return disk.value + } + @Suppress("UNCHECKED_CAST") private suspend inline fun cached(key: String, requestPermits: Semaphore = permits, crossinline block: suspend () -> T): T { - synchronized(metadataCache) { - metadataCache[key]?.takeIf { System.currentTimeMillis() - it.at < 30 * 60_000L }?.let { return it.value as T } + val memory = synchronized(metadataCache) { + metadataCache[key]?.takeIf { clock() - it.at in 0 until calendarMetadataTtl(it.value, clock()) } + } + if (memory != null) { + includeMetadata(memory.value, memory.at) + return memory.value as T } val lockPool = when { key.startsWith("season:") -> 1 @@ -427,17 +606,28 @@ class ReleaseCalendarRepository @Inject constructor( } return metadataLocks[lockPool][(key.hashCode() and Int.MAX_VALUE) % metadataLocks[lockPool].size].withLock { synchronized(metadataCache) { - metadataCache[key]?.takeIf { System.currentTimeMillis() - it.at < 30 * 60_000L }?.let { return@withLock it.value as T } - } - val disk = diskCache.readValue(key, object : TypeToken() {}.type) - val value = disk ?: requestPermits.withPermit { - withTimeoutOrNull(20_000) { block() } ?: throw IOException("Metadata request timed out") + if (missingMetadata[key]?.let { clock() - it in 0 until 15 * 60_000L } == true) { + throw IOException("Metadata not found") + } } - synchronized(metadataCache) { - metadataCache[key] = Cached(value, System.currentTimeMillis()) - while (metadataCache.size > 512) metadataCache.remove(metadataCache.keys.first()) + cachedValue(key)?.let { return@withLock it } + if (coroutineContext[TitleRead]?.cacheOnly == true) throw IOException("Metadata not cached") + val value = requestPermits.withPermit { + try { + withTimeoutOrNull(20_000) { block() } ?: throw IOException("Metadata request timed out") + } catch (error: HttpException) { + // A confirmed 404 should not trigger another network request on + // every date/month/tab change. Explicit Retry clears this cache. + if (error.code() == 404) synchronized(metadataCache) { + missingMetadata[key] = clock() + while (missingMetadata.size > 512) missingMetadata.remove(missingMetadata.keys.first()) + } + throw error + } } - if (disk == null) diskCache.writeValue(key, value) + rememberMetadata(key, value) + includeMetadata(value, clock()) + diskCache.writeValue(key, value) value } } diff --git a/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt b/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt index 35feeb8d1..5ddc16fdf 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt @@ -5,6 +5,7 @@ import com.arflix.tv.data.repository.CloudSyncInvalidationBus import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.ProfileManager import com.arflix.tv.data.repository.ProfileRepository +import com.arflix.tv.data.repository.ReleaseCalendarRepository import com.arflix.tv.data.repository.StreamRepository import com.arflix.tv.data.repository.TraktRepository import dagger.hilt.EntryPoint @@ -20,6 +21,7 @@ interface RepositoryAccessEntryPoint { fun profileManager(): ProfileManager fun cloudSyncInvalidationBus(): CloudSyncInvalidationBus fun tmdbApi(): TmdbApi + fun releaseCalendarRepository(): ReleaseCalendarRepository /** The discover filter "hide watched" reads the watched lists from here. */ fun traktRepository(): TraktRepository diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index 31ae3c718..5d014e6c7 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -7,9 +7,20 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -33,8 +44,33 @@ import com.arflix.tv.ui.screens.settings.SettingsScreen import com.arflix.tv.ui.screens.settings.telegram.TelegramSettingsScreen import com.arflix.tv.ui.screens.tv.live.LiveTvScreen import com.arflix.tv.ui.screens.watchlist.WatchlistScreen +import com.arflix.tv.ui.screens.watchlist.calendar.ReleaseCalendarViewModel import com.arflix.tv.ui.screens.profile.ProfileSelectionScreen import com.arflix.tv.util.LocalDeviceType +import kotlinx.coroutines.delay + +@Composable +private fun CalendarIdlePreload(viewModel: ReleaseCalendarViewModel, profileId: String?, content: @Composable () -> Unit) { + var lastInteraction by remember { mutableLongStateOf(0L) } + val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + LaunchedEffect(profileId, lifecycle, lastInteraction) { + if (profileId != null && lifecycle == Lifecycle.State.RESUMED) { + delay(6_000) + viewModel.preloadIdle() + } + } + Box(Modifier.fillMaxSize().onPreviewKeyEvent { + lastInteraction = android.os.SystemClock.uptimeMillis() + false + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + lastInteraction = android.os.SystemClock.uptimeMillis() + } + } + }) { content() } +} /** * Navigation destinations @@ -156,6 +192,8 @@ fun AppNavigation( onTvSubScreenChanged: (Boolean) -> Unit = {}, onExitApp: () -> Unit = {} ) { + // Activity-scoped results survive Home/Library navigation and remain profile-scoped internally. + val calendarViewModel: ReleaseCalendarViewModel = hiltViewModel() val navigateTopLevel: (String) -> Unit = { route -> navController.navigate(route) { popUpTo(Screen.Home.route) { saveState = true } @@ -209,6 +247,7 @@ fun AppNavigation( // Home screen composable(Screen.Home.route) { + CalendarIdlePreload(calendarViewModel, currentProfile?.id) { HomeScreen( preloadedCategories = preloadedCategories, preloadedHeroItem = preloadedHeroItem, @@ -254,6 +293,7 @@ fun AppNavigation( }, onExitApp = onExitApp ) + } } // Search screen @@ -277,9 +317,12 @@ fun AppNavigation( // Watchlist screen composable(Screen.Watchlist.route) { + DisposableEffect(calendarViewModel) { + calendarViewModel.onVisible() + onDispose { calendarViewModel.onHidden() } + } WatchlistScreen( - // Warm the current month while the user browses the other Library tabs. - calendarViewModel = hiltViewModel(), + calendarViewModel = calendarViewModel, currentProfile = currentProfile, onNavigateToDetails = { mediaType, mediaId -> navController.navigate(Screen.Details.createRoute(mediaType, mediaId)) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt index 29d2aec6a..4bdb0345c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt @@ -57,6 +57,7 @@ import com.arflix.tv.ui.screens.watchlist.calendar.* import java.time.LocalDate internal enum class LibrarySection(val label: String) { WATCHLISTS("Watchlists"), LISTS("My lists"), SERVERS("Homeserver"), CALENDAR("Calendar") } +internal val LibraryTabsTopInset = AppTopBarHeight - 8.dp internal fun libraryColumns(width: Int, poster: Boolean, collections: Boolean = false): Int = if (collections) (width / 270).coerceIn(1, 3) else if (poster) (width / 115).coerceIn(2, 8) else (width / 180).coerceIn(2, 4) internal fun WatchlistSourceItem.isPersonalCollection(): Boolean = this is WatchlistSourceItem.Catalog || @@ -201,18 +202,15 @@ fun WatchlistScreen( AppTopBar(SidebarItem.WATCHLIST, topFocused, topIndex, profile = currentProfile) } } - Column(Modifier.fillMaxSize().padding(top = when { - touch -> 0.dp - calendarMode -> AppTopBarHeight - 10.dp - else -> AppTopBarContentTopInset - }) + Column(Modifier.fillMaxSize().padding(top = if (touch) 0.dp else LibraryTabsTopInset) .padding(horizontal = if (compact) 16.dp else 26.dp)) { if (touch) Spacer(Modifier.height(12.dp)) Row(Modifier.fillMaxWidth().testTag("library-section-tabs").then(if (compact) Modifier.horizontalScroll(rememberScrollState()) else Modifier) - .padding(bottom = if(compact) 10.dp else if(calendarMode) 0.dp else 6.dp), verticalAlignment = Alignment.CenterVertically, + .heightIn(min = 48.dp) + .padding(bottom = if(compact) 10.dp else 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 8.dp)) { LibrarySection.entries.forEachIndexed { index, entry -> - OledControl(tr(entry.label), selected = section == entry, calendarTabStyle = calendarMode, + OledControl(tr(entry.label), selected = section == entry, modifier = (if(index == 0) Modifier.focusRequester(firstTab).onGloballyPositioned { if(!touch && !initialFocusPlaced) { initialFocusPlaced = true; firstTab.requestFocus() } } else if (entry == LibrarySection.CALENDAR) Modifier.focusRequester(calendarTab) else Modifier), compact = compact, @@ -329,18 +327,18 @@ fun WatchlistScreen( } @Composable -internal fun OledControl(label: String, modifier: Modifier = Modifier, selected: Boolean = false, compact: Boolean = false, icon: ImageVector? = null, maxLines: Int = 1, calendarTabStyle: Boolean = false, onClick: () -> Unit) { +internal fun OledControl(label: String, modifier: Modifier = Modifier, selected: Boolean = false, compact: Boolean = false, icon: ImageVector? = null, maxLines: Int = 1, onClick: () -> Unit) { var focused by remember { mutableStateOf(false) } val accent = resolveAccentColor(Color.White) - val foreground = if(calendarTabStyle && selected && !focused) Color.Black else if(focused) { if(accent.luminance() > .4f) Color.Black else Color.White } else Color.White - val background by animateColorAsState(if(focused) accent else if(selected && calendarTabStyle) Color.White else if(selected) Color(0xFF242426) else Color.Transparent, tween(120), label = "library-control") - Box(modifier.onFocusChanged { focused = it.isFocused }.clip(RoundedCornerShape(if (calendarTabStyle) 24.dp else 7.dp)) + val foreground = if(focused) { if(accent.luminance() > .4f) Color.Black else Color.White } else Color.White + val background by animateColorAsState(if(focused) accent else if(selected) Color(0xFF242426) else Color.Transparent, tween(120), label = "library-control") + Box(modifier.onFocusChanged { focused = it.isFocused }.clip(RoundedCornerShape(7.dp)) .background(background) - .clickable(onClick = onClick).padding(horizontal = if(compact) 8.dp else if(calendarTabStyle) 14.dp else 12.dp, vertical = if(calendarTabStyle && !compact) 5.dp else 11.dp), contentAlignment = Alignment.CenterStart) { + .clickable(onClick = onClick).padding(horizontal = if(compact) 8.dp else 12.dp, vertical = 11.dp), contentAlignment = Alignment.CenterStart) { if(label == "⌕") Icon(Icons.Outlined.Search, contentDescription = tr("Search library"), tint = foreground, modifier = Modifier.size(20.dp)) else Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { if(icon != null) Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(20.dp)) - Text(label, color = foreground, fontSize = if(calendarTabStyle && !compact) 12.sp else if(compact) 13.sp else 14.sp, fontWeight = if(selected || focused) FontWeight.SemiBold else FontWeight.Normal, + Text(label, color = foreground, fontSize = if(compact) 13.sp else 14.sp, fontWeight = if(selected || focused) FontWeight.SemiBold else FontWeight.Normal, maxLines = maxLines, overflow = TextOverflow.Ellipsis) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/LibraryCalendarPane.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/LibraryCalendarPane.kt index 4f4a9582f..d49fbfb42 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/LibraryCalendarPane.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/LibraryCalendarPane.kt @@ -97,7 +97,6 @@ fun LibraryCalendarPane( val monthButton = remember { FocusRequester() } val emptyAction = remember { FocusRequester() } val retryAction = remember { FocusRequester() } - var pendingFocus by remember { mutableStateOf(if (!touch) state.selectedDate else null) } var showSources by remember { mutableStateOf(false) } val releasesByDate = remember(state.entries, state.selectedSourceId) { state.releasesByDate } val selectedReleases = releasesByDate[state.selectedDate].orEmpty() @@ -120,16 +119,12 @@ fun LibraryCalendarPane( } } - LaunchedEffect(state.month, state.selectedDate, pendingFocus) { - pendingFocus?.let { date -> - requesters[date]?.let { requester -> - requester.requestFocus() - pendingFocus = null - } - } - } + LaunchedEffect(Unit) { if (!touch) requesters[state.selectedDate]?.requestFocus() } fun selectAndFocus(date: LocalDate) { - pendingFocus = date + // Keep remote movement on this month's stable nodes. A spillover cell + // must not replace the grid while a repeated key is still being handled. + if (YearMonth.from(date) != state.month) return + if (!touch) requesters[date]?.requestFocus() onSelectDate(date) } val retryModifier = Modifier.testTag("calendar-retry").focusRequester(retryAction) @@ -141,11 +136,12 @@ fun LibraryCalendarPane( BoxWithConstraints(modifier.fillMaxSize().testTag("library-calendar")) { val compact = maxWidth < 600.dp val compactToolbar = maxWidth < 800.dp - val scrollable = compact || maxHeight < 400.dp + val scrollable = compact || maxHeight < 320.dp val scroll = rememberScrollState() Column(Modifier.fillMaxSize().then(if (scrollable) Modifier.verticalScroll(scroll) else Modifier)) { CalendarToolbar(state, locale, compactToolbar, monthButton, onChangeMonth, - onSources = { showSources = true }, onExitUp = onExitUp) + onSources = { showSources = true }, onExitUp = onExitUp, + onEnterGrid = { requesters[state.selectedDate]?.requestFocus() }) if (compact && hasProblem) { CalendarButton(tr("Some release details unavailable") + " · " + tr("Retry"), retryModifier.padding(vertical = 5.dp), onClick = onRefresh) @@ -159,12 +155,13 @@ fun LibraryCalendarPane( } Column(Modifier.fillMaxWidth().then(if (scrollable) Modifier.height((dates.size / 7 * if (compact) 67 else 52).dp) else Modifier.weight(1f)) .testTag("calendar-month-grid"), verticalArrangement = Arrangement.spacedBy(4.dp)) { - dates.chunked(7).forEachIndexed { rowIndex, week -> + dates.chunked(7).forEach { week -> Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(if (compact) 3.dp else 4.dp)) { week.forEach { date -> CalendarDay(date, state.month, releasesByDate[date].orEmpty(), date == state.selectedDate, state.timezone, locale, compact, date == today, modifier = Modifier.weight(1f).fillMaxHeight().focusRequester(requesters.getValue(date)) + .focusProperties { canFocus = YearMonth.from(date) == state.month } .onPreviewKeyEvent { event -> val key = event.key.mirrorHorizontalForRtl(isRtl) if (key == Key.Enter || key == Key.DirectionCenter || key == Key.NumPadEnter) { @@ -176,11 +173,12 @@ fun LibraryCalendarPane( Key.DirectionLeft -> { selectAndFocus(date.minusDays(1)); true } Key.DirectionRight -> { selectAndFocus(date.plusDays(1)); true } Key.DirectionUp -> { - if (rowIndex == 0) monthButton.requestFocus() else selectAndFocus(date.minusWeeks(1)) + val target = date.minusWeeks(1) + if (YearMonth.from(target) != state.month) monthButton.requestFocus() else selectAndFocus(target) true } Key.DirectionDown -> { - if (rowIndex == dates.size / 7 - 1) { + if (YearMonth.from(date.plusWeeks(1)) != state.month) { openDay() } else selectAndFocus(date.plusWeeks(1)) true @@ -188,7 +186,7 @@ fun LibraryCalendarPane( else -> false } }, - onFocused = { if (!touch && date != state.selectedDate) onSelectDate(date) }, + onFocused = { if (!touch && YearMonth.from(date) == state.month && date != state.selectedDate) onSelectDate(date) }, onClick = { onSelectDate(date) }) } } @@ -264,9 +262,11 @@ fun LibraryCalendarPane( @Composable private fun CalendarToolbar(state: ReleaseCalendarUiState, locale: Locale, compact: Boolean, monthButton: FocusRequester, - onMonth: (Long) -> Unit, onSources: () -> Unit, onExitUp: () -> Unit) { + onMonth: (Long) -> Unit, onSources: () -> Unit, onExitUp: () -> Unit, onEnterGrid: () -> Unit) { val touch = LocalDeviceType.current.isTouchDevice() - Column { + Column(Modifier.onPreviewKeyEvent { + if (!touch && it.type == KeyEventType.KeyDown && it.key == Key.DirectionDown) { onEnterGrid(); true } else false + }) { Row(Modifier.fillMaxWidth().height(if (compact || touch) 44.dp else 24.dp) .onPreviewKeyEvent { if (!touch && it.type == KeyEventType.KeyDown && it.key == Key.DirectionUp) { onExitUp(); true } else false }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(if (compact) 3.dp else 10.dp)) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/ReleaseCalendarViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/ReleaseCalendarViewModel.kt index 97b2d15ba..a3b0b0c6b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/ReleaseCalendarViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/calendar/ReleaseCalendarViewModel.kt @@ -27,6 +27,7 @@ import java.time.ZoneId import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -36,7 +37,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull data class ReleaseCalendarUiState( val month: YearMonth = YearMonth.now(), @@ -79,12 +83,16 @@ class ReleaseCalendarViewModel @Inject constructor( private var contentLanguage = "en-US" private var privateCacheIdentity = "" private var previewSourceCounts = emptyMap() + private val readyIdentity = MutableStateFlow("") + private var foreground = false + private var backgroundLoad = false init { viewModelScope.launch { profileManager.activeProfileId.distinctUntilChanged().collectLatest { profileId -> activeProfileId = profileId privateCacheIdentity = "" + readyIdentity.value = "" sourceSnapshot = null sourceLoadedAt = 0L loadJob?.cancel() @@ -110,10 +118,14 @@ class ReleaseCalendarViewModel @Inject constructor( }.distinctUntilChanged().collectLatest { (items, connectionsKey, languageTag) -> contentLanguage = languageTag privateCacheIdentity = calendarCacheDigest("$profileId|$connectionsKey|$items|$languageTag") + readyIdentity.value = privateCacheIdentity + loadJob?.cancel() + requestId++ sourceSnapshot = null previewSourceCounts = emptyMap() _uiState.update { it.copy(entries = emptyList(), warnings = emptyList(), region = ContentRating.regionOf(languageTag)) } - reload(forceSources = true) + _uiState.update { it.copy(isLoading = true) } + if (foreground) reload(forceSources = true) } } } @@ -151,15 +163,56 @@ class ReleaseCalendarViewModel @Inject constructor( /** Safe to call on tab entry and ON_RESUME; fresh/in-flight results are reused. */ fun onVisible() { + foreground = true + backgroundLoad = false val timezone = ZoneId.systemDefault() val timezoneChanged = timezone != _uiState.value.timezone if (timezoneChanged) _uiState.update { it.copy(timezone = timezone, entries = emptyList()) } if (!timezoneChanged && loadJob?.isActive == true) return - if (timezoneChanged || sourceSnapshot == null || System.currentTimeMillis() - sourceLoadedAt >= 2 * 60_000L) { + if (timezoneChanged || _uiState.value.isLoading || sourceSnapshot == null || System.currentTimeMillis() - sourceLoadedAt >= 2 * 60_000L) { reload() } } + fun onHidden() { + foreground = false + loadJob?.takeIf { it.isActive }?.cancel() + } + + /** Called only after Home is idle; cancellation never cancels a promoted foreground load. */ + suspend fun preloadIdle() { + val identity = withTimeoutOrNull(5_000) { readyIdentity.first { it.isNotBlank() } } ?: return + if (foreground) return + if (loadJob?.isActive != true && (_uiState.value.isLoading || sourceSnapshot == null || + System.currentTimeMillis() - sourceLoadedAt >= 2 * 60_000L)) reload(background = true) + val job = loadJob + try { + job?.join() + if (identity != privateCacheIdentity || foreground || _uiState.value.isLoading) return + val snapshot = sourceSnapshot ?: return + val state = _uiState.value + val language = contentLanguage + for (offset in listOf(-1L, 1L)) { + val month = state.month.plusMonths(offset) + val key = "$identity|$month|${state.timezone.id}|${state.region}" + if (cache.readMonth(key) != null) continue + // Adjacent months use only metadata already fetched for this month. + // Missing metadata is filled normally when the user actually opens that month. + val result = withContext(Dispatchers.Default) { + repository.loadMonth(snapshot, month, state.timezone, state.region, language, cacheOnly = true) + } + if (identity != privateCacheIdentity || foreground) return + if (result.entries.isNotEmpty()) cache.writeMonth(key, CalendarMonthPreview(result.entries, + state.sources.associate { it.id to countForSource(it.id) })) + } + } finally { + if (loadJob === job && backgroundLoad && job?.isActive == true) { + requestId++ + job.cancel() + } + } + } + private fun countForSource(id: String): Int { val items = sourceSnapshot?.items ?: return previewSourceCounts[id] ?: 0 return if (id == ReleaseCalendarSource.ALL.id) mergeCalendarWatchlists(items).size @@ -167,6 +220,7 @@ class ReleaseCalendarViewModel @Inject constructor( } private fun updateSources(snapshot: CalendarWatchlists, complete: Boolean) { + if (sourceSnapshot === snapshot && (!complete || sourceLoadedAt != 0L)) return sourceSnapshot = snapshot // A partial source result must never be reused as a complete private snapshot. if (!complete) sourceLoadedAt = 0L @@ -180,11 +234,12 @@ class ReleaseCalendarViewModel @Inject constructor( } } - private fun reload(forceSources: Boolean = false, forceRefresh: Boolean = false) { + private fun reload(forceSources: Boolean = false, forceRefresh: Boolean = false, background: Boolean = false) { val profileId = activeProfileId ?: return if (privateCacheIdentity.isBlank()) return val sequence = ++requestId loadJob?.cancel() + backgroundLoad = background loadJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } try { @@ -194,6 +249,15 @@ class ReleaseCalendarViewModel @Inject constructor( val preview = if (!forceRefresh) cache.readMonth(cacheKey) else null var lastPreviewWrite = 0L var latestProgress: CalendarLoadProgress? = null + var lastUiPublish = 0L + fun publishEntries(progress: CalendarLoadProgress) { + val now = System.currentTimeMillis() + if (lastUiPublish == 0L || now - lastUiPublish >= 100L || + (_uiState.value.entries.isEmpty() && progress.month.entries.isNotEmpty())) { + lastUiPublish = now + _uiState.update { it.copy(entries = mergeCalendarPreview(preview?.entries.orEmpty(), progress), warnings = progress.month.warnings) } + } + } if (sequence != requestId || profileManager.getProfileIdSync() != profileId) return@launch if (preview != null) { previewSourceCounts = preview.sourceCounts @@ -209,21 +273,24 @@ class ReleaseCalendarViewModel @Inject constructor( } val result = if (snapshot != null) { updateSources(snapshot, complete = true) - repository.loadMonth(snapshot, current.month, current.timezone, current.region, language) { partial -> + withContext(Dispatchers.Default) { repository.loadMonth(snapshot, current.month, current.timezone, current.region, language) { partial -> + withContext(Dispatchers.Main.immediate) { if (sequence == requestId && profileManager.getProfileIdSync() == profileId) { // Cached source snapshots need the same failure/removal reconciliation // as fresh providers, including when changing back to an earlier month. val progress = CalendarLoadProgress(snapshot, partial, true, partial.completedTitles) latestProgress = progress - _uiState.update { it.copy(entries = mergeCalendarPreview(preview?.entries.orEmpty(), progress), warnings = partial.warnings) } + publishEntries(progress) } - }.also { completed -> latestProgress = CalendarLoadProgress(snapshot, completed, true, completed.completedTitles) } + } + } }.also { completed -> latestProgress = CalendarLoadProgress(snapshot, completed, true, completed.completedTitles) } } else { - repository.loadCalendar(profileId, current.month, current.timezone, current.region, language, forceRefresh) { progress -> + withContext(Dispatchers.Default) { repository.loadCalendar(profileId, current.month, current.timezone, current.region, language, forceRefresh) { progress -> + withContext(Dispatchers.Main.immediate) { if (sequence == requestId && profileManager.getProfileIdSync() == profileId) { latestProgress = progress updateSources(progress.watchlists, progress.watchlistsComplete) - _uiState.update { it.copy(entries = mergeCalendarPreview(preview?.entries.orEmpty(), progress), warnings = progress.month.warnings) } + publishEntries(progress) // The first usable dates also survive leaving the app while a slower // provider is still busy. Never replace a full preview with a partial one. val now = System.currentTimeMillis() @@ -233,7 +300,8 @@ class ReleaseCalendarViewModel @Inject constructor( _uiState.value.sources.associate { it.id to countForSource(it.id) })) } } - } + } + } } } if (sequence != requestId || profileManager.getProfileIdSync() != profileId) return@launch val finalEntries = latestProgress?.let { mergeCalendarPreview(preview?.entries.orEmpty(), it) } ?: result.entries diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCachePolicyTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCachePolicyTest.kt new file mode 100644 index 000000000..cb9481d6c --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCachePolicyTest.kt @@ -0,0 +1,37 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.api.* +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReleaseCalendarCachePolicyTest { + private val now = Instant.parse("2026-10-08T12:00:00Z").toEpochMilli() + + @Test fun `historical metadata lasts longer but active and unknown schedules stay fresh`() { + assertEquals(CALENDAR_HISTORY_TTL, calendarMetadataTtl(TmdbMovieDetails(releaseDate = "2020-01-01"), now)) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(TmdbMovieDetails(releaseDate = "2026-10-08"), now)) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(TmdbMovieDetails(), now)) + assertEquals(CALENDAR_HISTORY_TTL, calendarMetadataTtl(TmdbTvDetails(status = "Ended", + lastEpisodeToAir = TmdbEpisode(airDate = "2020-01-01")), now)) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(TmdbTvDetails(status = "Returning Series", + lastEpisodeToAir = TmdbEpisode(airDate = "2020-01-01")), now)) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(TmdbTvDetails(status = "Ended"), now)) + } + + @Test fun `upcoming regional movie releases prevent historical caching`() { + val movie = TmdbMovieDetails(releaseDate = "2020-01-01", releaseDates = TmdbReleaseDatesResponse(listOf( + TmdbReleaseDatesResult("NL", listOf(TmdbReleaseDate(releaseDate = "2026-10-20T00:00:00Z"))) + ))) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(movie, now)) + } + + @Test fun `specials future episodes and unknown air dates prevent historical season caching`() { + fun season(vararg dates: String?) = TmdbSeasonDetails(seasonNumber = 0, + episodes = dates.map { TmdbEpisode(airDate = it) }) + assertEquals(CALENDAR_HISTORY_TTL, calendarMetadataTtl(season("2020-01-01", "2021-01-01"), now)) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(season("2020-01-01", "2026-10-20"), now)) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(season("2020-01-01", null), now)) + assertEquals(CALENDAR_ACTIVE_TTL, calendarMetadataTtl(season(), now)) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCacheTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCacheTest.kt index 48488a687..43f6b240f 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCacheTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarCacheTest.kt @@ -89,4 +89,39 @@ class ReleaseCalendarCacheTest { time += 60 * 60_000L assertNull(ReleaseCalendarCache(directory) { time }.readMonth("month")) } + + @Test fun `metadata keeps original write time across process recreation and historical read window`() = runBlocking { + var time = System.currentTimeMillis() + val directory = temporary.newFolder() + val cache = ReleaseCalendarCache(directory) { time } + val season = TmdbSeasonDetails(episodes = listOf(TmdbEpisode(airDate = "2020-01-01"))) + cache.writeValue("season", season) + val writtenAt = time + time += 2 * 60 * 60_000L + assertEquals(CalendarMetadataRecord(season, writtenAt), + ReleaseCalendarCache(directory) { time }.readMetadata("season", TmdbSeasonDetails::class.java)) + time += 22 * 60 * 60_000L + assertNull(cache.readMetadata("season", TmdbSeasonDetails::class.java)) + } + + @Test fun `title projections round trip exactly expire and retry clears them but not full previews`() = runBlocking { + var time = System.currentTimeMillis() + val directory = temporary.newFolder() + val cache = ReleaseCalendarCache(directory) { time } + val entry = CalendarRelease("episode", MediaItem(1, "Show", mediaType = MediaType.TV), + LocalDate.of(2026, 10, 4), Instant.parse("2026-10-04T18:00:00Z"), CalendarReleaseKind.EPISODE, + 0, 1, "Special", setOf("trakt"), "https://art.example/logo.png") + val projection = CalendarTitleProjection(listOf(entry), time + 60_000L) + cache.writeTitle("private-title-key", projection) + assertEquals(projection, ReleaseCalendarCache(directory) { time }.readTitle("private-title-key")) + assertNull(cache.readTitle("other-profile-key")) + time += 60_000L + assertNull(cache.readTitle("private-title-key")) + cache.writeTitle("private-title-key", CalendarTitleProjection(emptyList(), time + 60_000L)) + assertTrue(cache.readTitle("private-title-key")!!.entries.isEmpty()) + cache.writeMonth("month", CalendarMonthPreview(listOf(entry), emptyMap(), time)) + cache.clearMetadata() + assertNull(cache.readTitle("private-title-key")) + assertNotNull(cache.readMonth("month")) + } } diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarIncrementalTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarIncrementalTest.kt new file mode 100644 index 000000000..4928d8b16 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarIncrementalTest.kt @@ -0,0 +1,184 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.api.* +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.ReleaseCalendarSource +import com.arflix.tv.data.repository.simkl.SimklAuthManager +import com.arflix.tv.data.repository.simkl.SimklSyncService +import com.arflix.tv.util.Constants +import io.mockk.* +import java.io.IOException +import java.time.Instant +import java.time.YearMonth +import java.time.ZoneId +import kotlinx.coroutines.test.runTest +import org.junit.* +import org.junit.Assert.* +import org.junit.rules.TemporaryFolder + +class ReleaseCalendarIncrementalTest { + @get:Rule val temporary = TemporaryFolder() + private val tmdb = mockk() + private val traktApi = mockk() + private val media = mockk() + private val profiles = mockk() + private var now = Instant.parse("2026-10-08T12:00:00Z").toEpochMilli() + private val month = YearMonth.of(2026, 10) + private val utc = ZoneId.of("UTC") + private lateinit var cache: ReleaseCalendarCache + private lateinit var repository: ReleaseCalendarRepository + + @Before fun setup() { + mockkObject(Constants) + every { Constants.TMDB_API_KEY } returns "fixture-key" + every { Constants.TRAKT_CLIENT_ID } returns "" + every { profiles.getProfileIdSync() } returns "fixture" + coEvery { media.getLogoUrl(any(), any()) } returns "https://art.example/logo.png" + cache = ReleaseCalendarCache(temporary.newFolder()) { now } + repository = createRepository() + } + + private fun createRepository() = ReleaseCalendarRepository(tmdb, traktApi, mockk(), + mockk(), mockk(), mockk(), mockk(), + media, profiles, cache).also { it.clock = { now } } + + private fun lists(vararg ids: Int, source: ReleaseCalendarSource = ReleaseCalendarSource.ARVIO) = + CalendarWatchlists("fixture", mapOf(source to ids.map { MediaItem(it, "Movie $it") })) + + @After fun cleanup() = unmockkAll() + + @Test fun `adding one title only fetches that title and successful removals are not restored`() = runTest { + coEvery { tmdb.getMovieDetails(any(), any(), any(), any()) } answers { + TmdbMovieDetails(firstArg(), "Movie ${firstArg()}", releaseDate = "2026-10-04") + } + val first = repository.loadMonth(lists(1, 2), month, utc, "NL") + val second = repository.loadMonth(lists(1, 2, 3), month, utc, "NL") + assertTrue(second.entries.containsAll(first.entries)) + assertEquals(setOf(1, 2, 3), second.entries.map { it.media.id }.toSet()) + val removed = repository.loadMonth(lists(2, 3), month, utc, "NL") + assertEquals(setOf(2, 3), removed.entries.map { it.media.id }.toSet()) + coVerify(exactly = 3) { tmdb.getMovieDetails(any(), any(), any(), any()) } + coVerify(exactly = 3) { media.getLogoUrl(any(), any()) } + } + + @Test fun `reused title schedules acquire only current source membership`() = runTest { + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Shared", releaseDate = "2026-10-04") + val original = lists(1).copy(items = lists(1).items + lists(1, source = ReleaseCalendarSource.TRAKT).items) + assertEquals(setOf("arvio", "trakt"), repository.loadMonth(original, month, utc, "NL").entries.single().sourceIds) + val current = repository.loadMonth(lists(1, source = ReleaseCalendarSource.TRAKT), month, utc, "NL") + assertEquals(setOf("trakt"), current.entries.single().sourceIds) + coVerify(exactly = 1) { tmdb.getMovieDetails(any(), any(), any(), any()) } + } + + @Test fun `process recreation reuses both nonempty and verified empty projections`() = runTest { + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Active", releaseDate = "2026-10-04") + coEvery { tmdb.getMovieDetails(2, any(), any(), any()) } returns TmdbMovieDetails(2, "Old", releaseDate = "2000-01-01") + val first = repository.loadMonth(lists(1, 2), month, utc, "NL") + val restarted = createRepository().loadMonth(lists(1, 2), month, utc, "NL") + assertEquals(first.entries, restarted.entries) + assertEquals(setOf(MediaType.MOVIE to 1, MediaType.MOVIE to 2), restarted.completedTitles) + coVerify(exactly = 2) { tmdb.getMovieDetails(any(), any(), any(), any()) } + coVerify(exactly = 1) { media.getLogoUrl(any(), any()) } + } + + @Test fun `active projections expire and changed dates replace old dates`() = runTest { + var day = "2026-10-04" + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } answers { TmdbMovieDetails(1, "Active", releaseDate = day) } + repository.loadMonth(lists(1), month, utc, "NL") + now += CALENDAR_ACTIVE_TTL + day = "2026-10-12" + val refreshed = createRepository().loadMonth(lists(1), month, utc, "NL") + assertEquals(month.atDay(12), refreshed.entries.single().date) + coVerify(exactly = 2) { tmdb.getMovieDetails(any(), any(), any(), any()) } + } + + @Test fun `historical projections survive an hour but expire after one day`() = runTest { + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Historical", releaseDate = "2000-01-01") + repository.loadMonth(lists(1), month, utc, "NL") + now += 60 * 60_000L + assertTrue(createRepository().loadMonth(lists(1), month, utc, "NL").entries.isEmpty()) + coVerify(exactly = 1) { tmdb.getMovieDetails(any(), any(), any(), any()) } + now += 23 * 60 * 60_000L + createRepository().loadMonth(lists(1), month, utc, "NL") + coVerify(exactly = 2) { tmdb.getMovieDetails(any(), any(), any(), any()) } + } + + @Test fun `disk restoration cannot extend a projection past its underlying metadata freshness`() = runTest { + cache.writeValue("movie:1:en-US", TmdbMovieDetails(1, "Active", releaseDate = "2026-10-04")) + now += CALENDAR_ACTIVE_TTL - 60_000L + repository.loadMonth(lists(1), month, utc, "NL") + coVerify(exactly = 0) { tmdb.getMovieDetails(any(), any(), any(), any()) } + now += 60_000L + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Active", releaseDate = "2026-10-12") + val refreshed = createRepository().loadMonth(lists(1), month, utc, "NL") + assertEquals(month.atDay(12), refreshed.entries.single().date) + coVerify(exactly = 1) { tmdb.getMovieDetails(any(), any(), any(), any()) } + } + + @Test fun `projection keys isolate profile language timezone region and optional Trakt identity`() = runTest { + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Active", releaseDate = "2026-10-04") + repository.loadMonth(lists(1), month, utc, "NL") + repository.loadMonth(lists(1), month, ZoneId.of("America/New_York"), "NL") + repository.loadMonth(lists(1), month, utc, "US") + repository.loadMonth(lists(1), month, utc, "NL", "nl-NL") + every { profiles.getProfileIdSync() } returns "other" + repository.loadMonth(lists(1).copy(profileId = "other"), month, utc, "NL") + coVerify(exactly = 5) { media.getLogoUrl(any(), any()) } + coVerify(exactly = 2) { tmdb.getMovieDetails(any(), any(), any(), any()) } + } + + @Test fun `adjacent month preloading never fetches uncached titles and normal entry fills every missing title`() = runTest { + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Active", releaseDate = "2026-11-04") + repository.loadMonth(lists(1), month, utc, "NL") + val preloaded = repository.loadMonth(lists(1, 2), month.plusMonths(1), utc, "NL", cacheOnly = true) + assertEquals(listOf(1), preloaded.entries.map { it.media.id }) + assertEquals(1, preloaded.warnings.size) + coVerify(exactly = 1) { tmdb.getMovieDetails(any(), any(), any(), any()) } + coVerify(exactly = 0) { media.getLogoUrl(any(), any()) } + coEvery { tmdb.getMovieDetails(2, any(), any(), any()) } returns TmdbMovieDetails(2, "New", releaseDate = "2026-11-06") + val opened = repository.loadMonth(lists(1, 2), month.plusMonths(1), utc, "NL") + assertEquals(setOf(1, 2), opened.entries.map { it.media.id }.toSet()) + assertTrue(opened.entries.all { it.logoUrl != null }) + coVerify(exactly = 2) { tmdb.getMovieDetails(any(), any(), any(), any()) } + } + + @Test fun `cache only adjacent seasons do not send batch individual time or artwork requests`() = runTest { + coEvery { tmdb.getTvDetails(5, any(), any(), any()) } returns TmdbTvDetails(5, "Show", seasons = listOf( + TmdbTvSeason(seasonNumber = 1, airDate = "2026-10-01"), + TmdbTvSeason(seasonNumber = 2, airDate = "2026-11-02"))) + coEvery { tmdb.getTvSeason(5, any(), any(), any()) } answers { + val n = secondArg() + TmdbSeasonDetails(seasonNumber = n, episodes = listOf(TmdbEpisode(seasonNumber = n, + airDate = if (n == 1) "2026-10-04" else "2026-11-04"))) + } + val lists = CalendarWatchlists("fixture", mapOf(ReleaseCalendarSource.ARVIO to listOf(MediaItem(5, "Show", mediaType = MediaType.TV)))) + repository.loadMonth(lists, month, utc, "NL") + val preload = repository.loadMonth(lists, month.plusMonths(1), utc, "NL", cacheOnly = true) + assertTrue(preload.warnings.isNotEmpty()) + coVerify(exactly = 1) { tmdb.getTvSeason(any(), any(), any(), any()) } + coVerify(exactly = 0) { tmdb.getTvSeasons(any(), any(), any(), any()) } + confirmVerified(traktApi) + assertEquals(2, repository.loadMonth(lists, month.plusMonths(1), utc, "NL").entries.single().seasonNumber) + coVerify(exactly = 2) { tmdb.getTvSeason(any(), any(), any(), any()) } + } + + @Test fun `incomplete season reads are not saved as complete empty months`() = runTest { + coEvery { tmdb.getTvDetails(5, any(), any(), any()) } returns TmdbTvDetails(5, "Show", seasons = listOf(TmdbTvSeason(seasonNumber = 1))) + coEvery { tmdb.getTvSeason(5, any(), any(), any()) } throws IOException("Temporary outage") + val lists = CalendarWatchlists("fixture", mapOf(ReleaseCalendarSource.ARVIO to listOf(MediaItem(5, "Show", mediaType = MediaType.TV)))) + assertTrue(repository.loadMonth(lists, month, utc, "NL").warnings.isNotEmpty()) + coEvery { tmdb.getTvSeason(5, any(), any(), any()) } returns TmdbSeasonDetails(episodes = listOf(TmdbEpisode(airDate = "2026-10-04"))) + assertEquals(1, createRepository().loadMonth(lists, month, utc, "NL").entries.size) + coVerify(exactly = 2) { tmdb.getTvSeason(any(), any(), any(), any()) } + } + + @Test fun `explicit retry clears title projections and fetches corrected schedules`() = runTest { + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Active", releaseDate = "2026-10-04") + repository.loadMonth(lists(1), month, utc, "NL") + repository.invalidateMetadata() + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } returns TmdbMovieDetails(1, "Active", releaseDate = "2026-10-12") + assertEquals(month.atDay(12), repository.loadMonth(lists(1), month, utc, "NL").entries.single().date) + coVerify(exactly = 2) { tmdb.getMovieDetails(any(), any(), any(), any()) } + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepositoryTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepositoryTest.kt index e2ec20549..d6981b912 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepositoryTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/ReleaseCalendarRepositoryTest.kt @@ -15,6 +15,8 @@ import com.arflix.tv.data.repository.simkl.SimklAuthManager import com.arflix.tv.data.repository.simkl.SimklSyncService import com.arflix.tv.data.repository.sync.RemoteWatchlistResult import com.arflix.tv.util.Constants +import com.google.gson.Gson +import com.google.gson.JsonObject import io.mockk.* import java.io.IOException import java.time.YearMonth @@ -35,6 +37,9 @@ import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import okhttp3.ResponseBody.Companion.toResponseBody @OptIn(ExperimentalCoroutinesApi::class) class ReleaseCalendarRepositoryTest { @@ -61,7 +66,9 @@ class ReleaseCalendarRepositoryTest { coEvery { simklAuth.isConnected() } returns false coEvery { mdb.getWatchlist() } returns RemoteWatchlistResult(false, null, 0) coEvery { media.getLogoUrl(any(), any()) } returns null - coEvery { cache.readValue(any(), any()) } returns null + coEvery { cache.readMetadata(any(), any()) } returns null + coEvery { cache.readTitle(any()) } returns null + coEvery { cache.writeTitle(any(), any()) } just Runs coEvery { cache.writeValue(any(), any()) } just Runs coEvery { cache.clearMetadata() } just Runs repository = ReleaseCalendarRepository(tmdb, traktApi, own, trakt, simklAuth, simkl, mdb, media, profiles, cache) @@ -133,6 +140,74 @@ class ReleaseCalendarRepositoryTest { assertEquals(setOf(0, 1, 2), result.entries.map { it.seasonNumber }.toSet()) } + @Test fun `twenty five seasons including specials need only two batch requests with identical releases`() = runTest { + val show = MediaItem(5, "Long running show", mediaType = MediaType.TV) + coEvery { tmdb.getTvDetails(5, any(), any(), any()) } returns TmdbTvDetails(5, "Long running show", + seasons = (0..24).map { TmdbTvSeason(seasonNumber = it, airDate = "2020-01-01") }) + val batches = mutableListOf>() + coEvery { tmdb.getTvSeasons(5, any(), any(), "en-US") } answers { + val seasons = thirdArg().split(',').map { it.substringAfter('/').toInt() } + batches += seasons + JsonObject().apply { + seasons.forEach { season -> add("season/$season", Gson().toJsonTree(TmdbSeasonDetails(seasonNumber = season, + episodes = listOf(TmdbEpisode(seasonNumber = season, episodeNumber = 1, airDate = "2026-10-04"))))) } + } + } + val lists = CalendarWatchlists("fixture-profile", mapOf(ReleaseCalendarSource.ARVIO to listOf(show))) + val result = repository.loadMonth(lists, month, ZoneId.of("UTC"), "US") + assertEquals((0..24).toSet(), result.entries.map { it.seasonNumber }.toSet()) + assertEquals(listOf(20, 5), batches.map { it.size }) + assertTrue(result.warnings.isEmpty()) + repository.loadMonth(lists, month.plusMonths(1), ZoneId.of("UTC"), "US") + coVerify(exactly = 2) { tmdb.getTvSeasons(any(), any(), any(), any()) } + coVerify(exactly = 0) { tmdb.getTvSeason(any(), any(), any(), any()) } + coVerify(exactly = 25) { cache.writeValue(match { it.startsWith("season:") }, any()) } + } + + @Test fun `partial season batch retries only missing blocks and preserves all dates`() = runTest { + val show = MediaItem(5, "Show", mediaType = MediaType.TV) + coEvery { tmdb.getTvDetails(5, any(), any(), any()) } returns TmdbTvDetails(5, "Show", + seasons = (0..2).map { TmdbTvSeason(seasonNumber = it) }) + fun season(number: Int) = TmdbSeasonDetails(seasonNumber = number, + episodes = listOf(TmdbEpisode(seasonNumber = number, airDate = "2026-10-04"))) + coEvery { tmdb.getTvSeasons(5, any(), any(), any()) } returns JsonObject().apply { + add("season/2", Gson().toJsonTree(season(2))) + addProperty("season/1", "invalid") + } + coEvery { tmdb.getTvSeason(5, any(), any(), any()) } answers { season(secondArg()) } + val result = repository.loadMonth(CalendarWatchlists("fixture-profile", mapOf(ReleaseCalendarSource.ARVIO to listOf(show))), + month, ZoneId.of("UTC"), "US") + assertEquals(setOf(0, 1, 2), result.entries.map { it.seasonNumber }.toSet()) + coVerify(exactly = 1) { tmdb.getTvSeason(5, 0, any(), any()) } + coVerify(exactly = 1) { tmdb.getTvSeason(5, 1, any(), any()) } + coVerify(exactly = 0) { tmdb.getTvSeason(5, 2, any(), any()) } + } + + @Test fun `batched seasons reuse individual disk cache and respect the five request limit`() = runTest { + var inFlight = 0 + var peak = 0 + coEvery { tmdb.getTvDetails(any(), any(), any(), any()) } answers { + TmdbTvDetails(firstArg(), "Show", seasons = (0..2).map { TmdbTvSeason(seasonNumber = it) }) + } + coEvery { cache.readMetadata(match { it.startsWith("season:") && it.contains(":0:") }, any()) } returns + CalendarMetadataRecord(TmdbSeasonDetails(seasonNumber = 0, episodes = listOf(TmdbEpisode(seasonNumber = 0, airDate = "2026-10-04"))), System.currentTimeMillis()) + coEvery { tmdb.getTvSeasons(any(), any(), any(), any()) } coAnswers { + assertEquals("season/2,season/1", thirdArg()) + inFlight++ + peak = maxOf(peak, inFlight) + delay(10) + inFlight-- + JsonObject().apply { (1..2).forEach { add("season/$it", Gson().toJsonTree(TmdbSeasonDetails(seasonNumber = it))) } } + } + val lists = CalendarWatchlists("fixture-profile", mapOf(ReleaseCalendarSource.ARVIO to + (1..12).map { MediaItem(it, "Show $it", mediaType = MediaType.TV) })) + val result = repository.loadMonth(lists, month, ZoneId.of("UTC"), "US") + assertEquals(12, result.entries.size) + assertTrue(peak <= 2) + coVerify(exactly = 12) { tmdb.getTvSeasons(any(), any(), any(), any()) } + coVerify(exactly = 0) { tmdb.getTvSeason(any(), any(), any(), any()) } + } + @Test fun `large watchlist uses at most five parallel metadata requests and reuses cache`() = runTest { var inFlight = 0 var peak = 0 @@ -152,6 +227,58 @@ class ReleaseCalendarRepositoryTest { coVerify(exactly = 18) { tmdb.getMovieDetails(any(), any(), any(), any()) } } + @Test fun `current show releases are prioritized ahead of a large old movie backlog`() = runTest { + val show = MediaItem(500, "Current show", mediaType = MediaType.TV) + coEvery { tmdb.getTvDetails(500, any(), any(), any()) } returns TmdbTvDetails(500, "Current show", + nextEpisodeToAir = TmdbEpisode(airDate = "2026-10-04")) + coEvery { tmdb.getMovieDetails(any(), any(), any(), any()) } coAnswers { + delay(1_000) + TmdbMovieDetails(firstArg(), "Old movie", releaseDate = "2000-01-01") + } + val updates = mutableListOf>() + val lists = CalendarWatchlists("fixture-profile", mapOf(ReleaseCalendarSource.ARVIO to + (1..100).map { MediaItem(it, "Old movie") } + show)) + val request = async { repository.loadMonth(lists, month, ZoneId.of("UTC"), "US") { updates += currentTime to it } } + runCurrent() + assertTrue(updates.any { (time, result) -> time == 0L && result.entries.any { it.media.id == 500 } }) + assertFalse(request.isCompleted) + request.cancelAndJoin() + } + + @Test fun `confirmed missing metadata does not refetch until explicit refresh`() = runTest { + coEvery { tmdb.getMovieDetails(1, any(), any(), any()) } throws + HttpException(Response.error(404, "{}".toResponseBody())) + val lists = repository.loadWatchlists("fixture-profile") + repeat(2) { + assertEquals(1, repository.loadMonth(lists, month, ZoneId.of("UTC"), "US").warnings.size) + } + coVerify(exactly = 1) { tmdb.getMovieDetails(1, any(), any(), any()) } + repository.invalidateMetadata() + repository.loadMonth(lists, month, ZoneId.of("UTC"), "US") + coVerify(exactly = 2) { tmdb.getMovieDetails(1, any(), any(), any()) } + } + + @Test fun `later provider show is not queued behind hundreds of old movies`() = runTest { + coEvery { own.getLocalWatchlistItems() } returns (1..100).map { MediaItem(it, "Old movie") } + every { trakt.isAuthenticated } returns flowOf(true) + coEvery { trakt.getWatchlistSyncResultWithAuthState() } coAnswers { + delay(1) + true to TraktRepository.WatchlistSyncResult(listOf(MediaItem(500, "Current show", mediaType = MediaType.TV)), 1) + } + coEvery { tmdb.getMovieDetails(any(), any(), any(), any()) } coAnswers { + delay(1_000) + TmdbMovieDetails(firstArg(), "Old movie", releaseDate = "2000-01-01") + } + coEvery { tmdb.getTvDetails(500, any(), any(), any()) } returns TmdbTvDetails(500, "Current show", + nextEpisodeToAir = TmdbEpisode(airDate = "2026-10-04")) + val updates = mutableListOf>() + val request = async { repository.loadCalendar("fixture-profile", month, ZoneId.of("UTC"), "US") { updates += currentTime to it } } + advanceTimeBy(1_001) + runCurrent() + assertTrue(updates.any { (time, progress) -> time <= 1_000L && progress.month.entries.any { it.media.id == 500 } }) + request.cancelAndJoin() + } + @Test fun `localized metadata cache cannot leak the previous language`() = runTest { coEvery { tmdb.getMovieDetails(1, any(), any(), "en-US") } returns TmdbMovieDetails(1, "English title", releaseDate = "2026-10-04") coEvery { tmdb.getMovieDetails(1, any(), any(), "nl-NL") } returns TmdbMovieDetails(1, "Nederlandse titel", releaseDate = "2026-10-04") @@ -319,8 +446,8 @@ class ReleaseCalendarRepositoryTest { } @Test fun `public metadata restored from disk needs no network after repository recreation`() = runTest { - coEvery { cache.readValue("movie:1:en-US", any()) } returns - TmdbMovieDetails(1, "Saved movie", releaseDate = "2026-10-04") + coEvery { cache.readMetadata("movie:1:en-US", any()) } returns + CalendarMetadataRecord(TmdbMovieDetails(1, "Saved movie", releaseDate = "2026-10-04"), System.currentTimeMillis()) val result = repository.loadMonth(repository.loadWatchlists("fixture-profile"), month, ZoneId.of("UTC"), "US") assertEquals("Saved movie", result.entries.single().media.title) coVerify(exactly = 0) { tmdb.getMovieDetails(any(), any(), any(), any()) } diff --git a/docs/library-calendar.md b/docs/library-calendar.md index 75633e671..83940146e 100644 --- a/docs/library-calendar.md +++ b/docs/library-calendar.md @@ -26,6 +26,10 @@ The month starts on Monday. Previous/next month controls work across year boundaries and keep the selected day where possible. On TV, left/right moves one day, up/down moves a week, and OK enters the selected day's release strip. OK on a release opens its details. Up returns from that strip to the selected day. +Remote day navigation stays inside the displayed month; adjacent-month cells are +not remote focus targets. Use the month controls to change months. Up from the +first week enters those controls, and Down returns to the selected date, rather +than relying on geometric focus search across spillover days. Phone layouts retain the month artwork and expose the larger selected-day cards below it. Short landscape windows scroll vertically instead of clipping the month @@ -43,7 +47,14 @@ toolbar only contains month navigation, the watchlist filter and connected-servi branding; it has no Today button, timezone caption, refresh button or navigation instruction footer. Retry remains available when a source fails. Phone date cells prioritize artwork and release counts; complete titles and times remain in the selected-day strip. -On TV the Calendar content starts 26 dp closer to the unchanged shared topbar. +Calendar uses the same Library tab typography, shape, padding and vertical position +as Watchlists, My lists and Homeserver. The tab row has a stable minimum height, +including when other sections expose additional controls. Normal TV windows keep +the month toolbar and release strip in view while navigating dates; only compact +or genuinely short windows scroll the entire month surface. +The TV Library tabs start at 74 dp, just below the visible topbar controls, rather +than reserving the full 98 dp gradient area as empty space. This tighter spacing +is shared by all four Library sections; topbar bounds and touch spacing stay unchanged. Dates with multiple releases show up to three portrait posters and a `+N` count for further releases, both focused and unfocused. Single-release cells retain their title and time. The wide web layout follows the same poster-strip design. @@ -60,12 +71,20 @@ in an Android vector; PNG artwork retains its original colors and aspect ratio. ## Loading and isolation -Metadata requests use bounded concurrency and expiring caches. Android keeps public -metadata for 30 minutes in memory/on disk and restores a private month preview for +Metadata requests use bounded concurrency and expiring caches. Android keeps active, +future and unknown public metadata for 30 minutes in memory/on disk; older movie, +completed show and fully historical season metadata can be reused for 24 hours. +Upcoming regional releases or undated episodes retain the short refresh interval. +Android restores a private month preview for up to 24 hours, scoped to profile, connection identity, local watchlist membership, language, region, month and timezone. Encoding, parsing and file access run off the -UI thread. The production Library route preloads the current month before the -Calendar tab opens. The web client also restores scoped month snapshots before +UI thread. Home starts warming the selected Calendar month after six seconds with +no remote/touch input, only while resumed. Input, leaving Home or backgrounding +cancels idle work. Opening Library promotes an in-flight load rather than restarting +it; incomplete cancelled work resumes on entry. The activity-scoped ViewModel +shares results between Home and Library, while private state remains profile-scoped. +The production Library route also starts loading before the Calendar tab opens. +The web client restores scoped month snapshots before refreshing. Successfully removed source memberships are reconciled into the saved preview even when an unrelated service fails; retained fallback data keeps its original expiry. New, uncached remote data still requires a network response. @@ -82,6 +101,29 @@ Historical season requests have their own bounded slots and start with recent seasons, allowing newly arriving titles to publish dates without waiting for a complete season walk. Metadata for an identical request is coalesced; no titles or potentially relevant seasons are silently omitted. +Android batches up to 20 missing seasons per TMDB request using +`append_to_response`, preserving the normal per-season cache keys. Specials and +overlapping seasons remain included. Missing or malformed appended blocks retry +independently through the ordinary season endpoint. Batch decoding runs off the UI +thread; the same five-request global limit and two-season-request limit still apply. +Current shows and recent movie releases are prioritized without excluding other +titles. Completed per-title month projections, including verified empty results, +survive process recreation. Adding one watchlist title only computes that title; +saved results acquire current source memberships and cannot restore removed titles. +Projection keys include profile, media identity, month, language, region, timezone +and optional Trakt identity. Expiry never exceeds the freshness of the metadata +used to compute the result. Errors are not saved as successful empty schedules; +missing optional artwork receives a short recovery interval. Explicit Retry clears +metadata and projections but retains private full-month previews for outage fallback. +After idle warming, adjacent months reuse already fetched metadata without issuing +extra HTTP requests. Normal month entry still fills every missing title/season. +Caches are bounded in memory and on disk (4,096 files / 48 MiB maximum). +Progress parsing/mapping runs off Main; UI updates are coalesced at 100 ms while +the first available release and final complete result remain immediate. +Older movies have a bounded title queue so later-arriving tracker shows do +not wait behind hundreds of pending requests. Confirmed HTTP 404 responses are +remembered for fifteen minutes to avoid repeated failed reads on date/month changes; +explicit Retry clears this negative cache. Other network errors are not hidden by it. Changing month, profile or language cancels/replaces the old request. Private watchlists are scoped to the current account/profile; public metadata may be cached. Android refreshes stale lists on tab entry/resume and reacts to cloud watchlist and @@ -146,3 +188,56 @@ shared-topbar emulator scenarios. Assertions check unchanged topbar bounds, no overlap with the closer Calendar tabs, three posters within five-/six-week cells, and correct overflow. Web browser checks passed at all five existing viewport sizes, along with TypeScript. Evidence is in `artifacts/calendar-posters-oct5`. + +### Validation on 8 October 2026 + +- Android: all 42 Calendar repository, mapping and persistent-cache unit tests + passed. Regressions cover season batching, malformed/missing batch blocks, + specials, unchanged release results, late-arriving shows and confirmed 404s. +- Physical TV: ten Calendar UI scenarios passed on an Android 14 TV at + 1920 x 1080. The two phone-only scenarios were skipped on this landscape device. + Checks cover identical Library tab bounds, month boundaries, repeated native + remote keys, toolbar/grid transitions, filtering and opening release details. +- The configured account benchmark checked 459 watchlist titles and found 45 + releases. Cold first release: 3.276 seconds; last newly discovered release: + 21.673 seconds; complete cold scan: 26.291 seconds. Cached first release: + 0.199 seconds; complete cached scan: 2.130 seconds, with the identical release + ID set. These are repository delivery times, not complete image-render times. +- The account still produced one provider warning and 28 metadata warnings. + Two sampled unavailable movie records returned confirmed HTTP 404 responses; + the remaining warnings were not individually diagnosed. Partial results and + Retry remain visible rather than silently discarding unavailable records. +- The optimized sideload release APK was installed as a signed update with the + existing certificate and retained profiles/settings. A release-app check + confirmed both month boundaries after 35 presses in each direction, consistent + tabs, populated real-account artwork and opening a Calendar title. No playback + was started during Calendar checks. The full cold scan is not instant. +- Evidence is saved locally in `artifacts/calendar-tv-*.log` and + `artifacts/calendar-*-release*.png`. This change was tested on Android; the web + Calendar implementation was not modified or revalidated in this pass. + +The subsequent tab-spacing refinement passed the same ten physical-TV scenarios +(two portrait-phone scenarios skipped). Checks now switch between all four Library +sections and require a gap of at most 12 dp below visible topbar controls, with no +overlap when those controls are focused. Topbar bounds and touch spacing are unchanged. + +### Incremental loading validation on 8 October 2026 + +- All 58 Calendar unit tests passed, including persistent per-title results, + incremental additions/removals, source reconciliation, metadata/projection expiry, + process recreation, locale/timezone/profile isolation and cache-only adjacent months. +- The ten physical-TV Calendar UI scenarios passed again (two portrait-only checks + skipped). Both additional ViewModel lifecycle tests passed: no eager network load, + foreground promotion surviving idle cancellation, and resuming an incomplete + cancelled load even after provider reads completed. An initial fixture callback + incorrectly selected the coroutine continuation; the corrected tests were rerun. +- The same real account retained all 45 releases from 459 titles. First cached release: + 90 ms; complete cached result: 1.040 seconds versus the earlier 2.130 seconds. + Full cold scan: 31.969 seconds, first release 4.156 seconds and last new release + 23.554 seconds. Network conditions and new cache writes affect cold timing; + first-ever network loading is not instant. These measure repository delivery, + not complete artwork rendering. +- One provider warning and 28 metadata warnings remained; the two sampled movies + again returned HTTP 404. Partial results and explicit Retry remain available. +- Evidence: `artifacts/calendar-incremental-*.log` and + `artifacts/calendar-preload-tv-tests-fixed.log`. No private account data is committed.