Skip to content

Implements Paging Support for Issue #374#516

Merged
kingargyle merged 11 commits intodevelopfrom
dac_paging_support
Feb 8, 2026
Merged

Implements Paging Support for Issue #374#516
kingargyle merged 11 commits intodevelopfrom
dac_paging_support

Conversation

@kingargyle
Copy link
Contributor

@kingargyle kingargyle commented Feb 7, 2026

Updates the code to make use of the PagingAdapters and Paging Libraries. This is a pretty big refactor adding, paging support to the Serenity Client, as well as updating both the EmbyAPIClient and JellfyinClients for this support.

Unit tests have been added for most of the code changes.

With these changes, time to first image appearing is reduced by almost half on a TCL TV.

Implements #374

Summary by CodeRabbit

  • New Features

    • App-wide pagination for similar items, episodes, categories and detail screens; paging-aware adapters for Leanback UI.
  • Performance

    • Parallelized media list construction for faster content availability.
  • Chores

    • Added runtime paging libraries and propagated pagination support through clients, services, repositories, presenters, and UI.
  • Tests

    • New unit tests for paging sources, adapters, repositories, and related presenter behavior.
  • Documentation

    • Added a multi-phase paging support plan.

…paging support

Implements the paging data sources that are needed for the Leanback Paging library
support.  Also updates the Emby Client to work with paging.

Includes a plan for ais to help generate the rest of the items.
Process the lists in multiple chunks.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 7, 2026

Warning

Rate limit exceeded

@kingargyle has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 13 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

Adds end-to-end paging: paginated client overloads and Retrofit query params, PagingSource implementations, a Leanback PagingData adapter bridge, repository and presenter plumbing producing PagingData flows, UI/adapter changes to consume PagingData, concurrent model builders, tests, and dependency additions. (50 words)

Changes

Cohort / File(s) Summary
API clients
emby-lib/src/main/kotlin/.../EmbyAPIClient.kt, jellyfin-lib/src/main/kotlin/.../JellyfinAPIClient.kt
Added paginated overloads (fetchSimilarItemById(..., startIndex, limit) and retrieveEpisodes(..., startIndex, limit)), updated delegating variants, made includeItemType dynamic, and added lazy userId initialization.
Retrofit services
emby-lib/src/main/kotlin/.../UsersService.kt, jellyfin-lib/src/main/kotlin/.../JellyfinUsersService.kt
Added @Query("StartIndex") startIndex: Int = 0 and @Query("Limit") limit: Int? = null to item-fetch endpoints; removed hard-coded Limit from latestItems path.
Common client interface
serenity-common/src/main/java/.../SerenityClient.kt
Added paginated overloads for fetchSimilarItemById and retrieveEpisodes with default values.
Repository layer
serenity-app/src/main/kotlin/.../core/repository/VideoRepository.kt, .../CategoryRepository.kt
Propagated pagination params through repository methods and client calls; added fetchSimilarItemsList(...) helper returning List.
Paging sources
serenity-app/src/main/kotlin/.../core/paging/*PagingSource.kt
Added EpisodePagingSource, SimilarItemsPagingSource, VideoCategoryPagingSource implementing PagingSource with load/getRefreshKey and error handling.
Paging adapter bridge
serenity-app/src/main/kotlin/.../ui/leanback/adapters/SerenityPagingDataAdapter.kt
New adapter bridging PagingDataAdapter to Leanback ObjectAdapter; registers DataObserver and exposes suspend submitData.
UI / Leanback
serenity-app/src/main/java/.../fragments/MainMenuFragment.kt, .../MainMenuVideoContentVerticalGridFragment.kt, .../ui/activity/leanback/details/*
Switched view method signatures from List to PagingData<T>; replaced ArrayObjectAdapter flows with SerenityPagingDataAdapter and lifecycleScope submitData usage.
Presenters
serenity-app/src/main/kotlin/.../fragments/mainmenu/MainMenuPresenter.kt, .../ui/activity/leanback/details/DetailsMVPPresenter.kt
Replaced synchronous fetches with Pager-based flows; use map/collectLatest/cachedIn and paging sources per feature.
Model concurrency
serenity-app/src/main/kotlin/.../core/model/impl/{EpisodeMediaContainer,MovieMediaContainer}.kt
Reworked video creation to return Lists and process source items concurrently in chunked coroutine batches.
Tests
serenity-app/src/test/kotlin/.../core/paging/*PagingSourceTest.kt, .../core/repository/*RepositoryTest.kt, emby-lib/.../EmbyAPIClientTest.kt, .../ui/leanback/adapters/SerenityPagingDataAdapterTest.kt
Added unit tests for PagingSources, repository paging parameter propagation, SerenityPagingDataAdapter behavior, and adjusted Emby API test setup.
Build & deps
gradle/libs.versions.toml, serenity-app/build.gradle.kts
Added paging and leanback-paging versions and app dependencies (androidx.paging.runtime.ktx, androidx.leanback.paging).
Docs & plan
paging_support_plan.md, prompts/mvp_viewmodel_prompt.txt
New paging rollout plan and MVP→ViewModel migration prompt/documentation.
Lint & misc
lint-rules/*, lint-rules/src/test/*
Minor formatting/refactor in lint detector and adjusted tests expected strings.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as DetailsFragment
    participant Presenter as DetailsMVPPresenter
    participant Pager as Pager
    participant PS as SimilarItemsPagingSource
    participant Repo as VideoRepository
    participant Client as SerenityClient
    participant Adapter as SerenityPagingDataAdapter

    User->>UI: open details / scroll similar
    UI->>Presenter: loadSimilarItems(itemId, type)
    Presenter->>Pager: create Pager(...) -> flow
    Presenter->>Adapter: create SerenityPagingDataAdapter
    Presenter->>Adapter: submitData(flow.cachedIn(scope))

    loop pages loaded
        Pager->>PS: load(position, loadSize)
        PS->>Repo: fetchSimilarItemsList(itemId, type, startIndex, limit)
        Repo->>Client: fetchSimilarItemById(itemId,type,startIndex,limit)
        Client-->>Repo: IMediaContainer
        Repo-->>PS: List<VideoContentInfo>
        PS-->>Pager: LoadResult.Page(data, prevKey, nextKey)
        Pager->>Adapter: notify updates
        Adapter->>UI: DataObserver notifies Leanback
    end
Loading
sequenceDiagram
    participant User as User
    participant Presenter as MainMenuPresenter
    participant Pager as Pager
    participant PS as VideoCategoryPagingSource
    participant Repo as CategoryRepository
    participant Client as SerenityClient
    participant View as MainMenuFragment
    participant Adapter as SerenityPagingDataAdapter

    User->>Presenter: navigate to main menu
    Presenter->>Presenter: load categories (initial)
    loop per category
        Presenter->>Pager: Pager(...) -> VideoCategoryPagingSource
        Presenter->>View: updateCategories(category, pagingData)
        View->>Adapter: create per-category SerenityPagingDataAdapter
        View->>Adapter: submitData(pagingData)
    end

    loop user scrolls
        Pager->>PS: load(position, loadSize)
        PS->>Repo: fetchItemsByCategory(categoryId,itemId,type,startIndex,limit)
        Repo->>Client: retrieveItemByIdCategory(...)
        Client-->>Repo: IMediaContainer
        Repo-->>PS: List<VideoContentInfo>
        PS-->>Pager: LoadResult.Page(...)
        Adapter->>View: DataObserver triggers UI update
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through pages, soft and spry,

tiles arrive as coroutines fly,
adapters bridge each scrolling beat,
streams of items flow so neat,
hop, fetch, display — a paged delight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Implements Paging Support for Issue #374' directly describes the primary objective of the changeset: adding pagination support throughout the codebase using PagingAdapters and Paging libraries.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dac_paging_support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (9)
emby-lib/src/test/kotlin/us/nineworlds/serenity/emby/server/api/EmbyAPIClientTest.kt (1)

56-66: ⚠️ Potential issue | 🟡 Minor

Inconsistent base URL: setUp uses 192.168.68.97:8096 but loginUserWithPassword uses yourserver:yourport.

Line 34 sets the URL to a specific IP, but line 58 overrides it with the placeholder "http://yourserver:yourport". This test will always fail since neither URL is likely to be valid in most environments. If the intent is to skip this in CI, consider adding @Ignore with a comment.

emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/server/api/EmbyAPIClient.kt (1)

241-263: ⚠️ Potential issue | 🔴 Critical

startIndex parameter is accepted but never passed to the service call — pagination for similar items is broken.

In fetchSimilarItemById (lines 241-263), the startIndex parameter is accepted but not forwarded to the usersService.fetchSimilarItemById() call. Additionally, UsersService.fetchSimilarItemById (defined in UsersService.kt) does not define a @Query("StartIndex") parameter at all.

This breaks pagination: SimilarItemsPagingSource calls repository.fetchSimilarItemsList() with incrementing position values, but EmbyAPIClient ignores those positions when calling the API, causing every page request to return the same first page of results.

Add @Query("StartIndex") startIndex: Int = 0 to UsersService.fetchSimilarItemById and pass it through in the EmbyAPIClient call.

emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/server/api/UsersService.kt (1)

40-58: ⚠️ Potential issue | 🟠 Major

Remove LimitCount query parameter from fetchItemQuery — not a valid Emby API parameter.

Per Emby's official REST API reference, the Items endpoint (GET /Users/{UserId}/Items) supports only Limit and StartIndex for pagination. LimitCount is not a documented parameter and including it will send an invalid query parameter to the API. The new Limit parameter is correct; remove the pre-existing LimitCount (line 51) to avoid potential API conflicts or unexpected behavior.

serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsMVPPresenter.kt (1)

86-112: ⚠️ Potential issue | 🟠 Major

Add explicit Dispatchers.Main to presenterScope.launch call for thread safety.

presenterScope does not default to the Main dispatcher — the dispatcher is inherited from the coroutine context or defaults to the inherited one. At line 106, presenterScope.launch { collectLatest { viewState.updateSeasonEpisodes(...) } } will execute on whichever dispatcher is active, which may not be the Main thread. Since Moxy view state commands must run on the Main thread, explicitly specify Dispatchers.Main:

presenterScope.launch(Dispatchers.Main) {
    pagingFlow.collectLatest { pagingData ->
        viewState.updateSeasonEpisodes(season, pagingData)
    }
}

(Note: the updateSeries function at line 89 correctly uses withContext(Dispatchers.Main) for the similar operation; apply the same pattern here.)

serenity-app/src/main/java/us/nineworlds/serenity/fragments/MainMenuVideoContentVerticalGridFragment.kt (1)

108-122: ⚠️ Potential issue | 🟠 Major

Adapter created but unreachable when categoryDetail is null.

On Line 113, if category.categoryDetail is null, the SerenityPagingDataAdapter is still created and added as a ListRow to videoContentAdapter (Line 120), but it is never stored in adapterMap. This means updateCategory (Line 101) can never find it and will silently skip submitting paging data, leaving an empty row in the UI.

Either skip adding the ListRow entirely when categoryDetail is null, or ensure categoryDetail is always non-null at this point.

Proposed fix — skip categories with null detail
         for (category in categories.categories) {
+            val detail = category.categoryDetail ?: continue
             val listContentRowAdapter = SerenityPagingDataAdapter(CategoryVideoPresenter(), videoCategoryDiffCallback)
-            category.categoryDetail?.let { detail ->
-                adapterMap[detail] = listContentRowAdapter
-            }
+            adapterMap[detail] = listContentRowAdapter
 
-            val header = HeaderItem(category.categoryDetail)
+            val header = HeaderItem(detail)
             val imageListRow = ListRow(header, listContentRowAdapter)
 
             videoContentAdapter.add(imageListRow)
         }
jellyfin-lib/src/main/kotlin/us/nineworlds/serenity/jellyfin/server/api/JellyfinAPIClient.kt (2)

461-475: ⚠️ Potential issue | 🟡 Minor

playSessionId parameter is accepted but silently ignored.

The progress method at Line 463 accepts playSessionId: String? but never passes it to usersService.progress (Line 471). If this parameter is intentionally unsupported on Jellyfin, consider removing it from the signature or adding a comment explaining why it's ignored, to avoid confusing future maintainers.


240-262: ⚠️ Potential issue | 🔴 Critical

startIndex parameter accepted but cannot be used — Jellyfin API doesn't support pagination for the Similar endpoint.

The method accepts startIndex: Int (Line 240) but never passes it to usersService.fetchSimilarItemById (Lines 252–258). This is because the Jellyfin API's /Items/{itemId}/Similar endpoint does not support a StartIndex query parameter—it only supports limit, userId, fields, and excludeArtistIds.

Consequently, SimilarItemsPagingSource will always fetch results from the beginning of the list on every page, retrieving duplicate data rather than actual pagination.

Recommended fix: Either remove startIndex from this method's signature or mark it as a no-op with a comment explaining that Jellyfin API doesn't support offset-based pagination for similar items. If pagination is required, verify whether an alternative endpoint or approach exists.

jellyfin-lib/src/main/kotlin/us/nineworlds/serenity/jellyfin/server/api/JellyfinUsersService.kt (2)

90-105: ⚠️ Potential issue | 🔴 Critical

Duplicate Limit parameter — hardcoded in @GET URL and also as @Query.

Line 90 hardcodes ?Limit=20 in the URL annotation, while Line 104 adds @Query("Limit") limit: Int? = null. When a non-null limit is passed (as it will be during paging), Retrofit appends &Limit=<value> to the URL, producing a request like:

/Users/{userId}/Items?Limit=20&...&Limit=30

The server behavior with duplicate query parameters is undefined and will likely cause incorrect page sizes.

Proposed fix — remove hardcoded Limit from the URL
-    `@GET`("/Users/{userId}/Items?Limit=20")
+    `@GET`("/Users/{userId}/Items")
     fun latestItems(
         `@HeaderMap` headerMap: Map<String, String>,
         `@Path`("userId") userId: String,
         `@Query`("ParentId") parentId: String,
         `@Query`("Recursive") recursive: Boolean = true,
         `@Query`("SortBy") sortOptions: String = "DateCreated",
         `@Query`("SortOrder") sortOrder: String = "Descending",
         `@Query`("IsPlayed") isPlayed: Boolean = false,
         `@Query`("Filters") filters: String = "IsNotFolder,IsUnPlayed",
         `@Suppress`("ktlint:standard:max-line-length")
         `@Query`("Fields") fields: String = "Overview,MediaStreams,Studios,ParentId,Genres,MediaSources,UserData,OfficialRating,CommunityRating",
         `@Query`("IncludeItemTypes") includeItemType: String? = null,
         `@Query`("StartIndex") startIndex: Int = 0,
-        `@Query`("Limit") limit: Int? = null
+        `@Query`("Limit") limit: Int? = 20
     ): Call<QueryResult>

147-156: ⚠️ Potential issue | 🔴 Critical

Missing StartIndex parameter on fetchSimilarItemById.

This endpoint lacks a @Query("StartIndex") parameter, so it cannot support offset-based pagination. As flagged in the JellyfinAPIClient review, the startIndex accepted by the client method has no way to reach the server. If the Jellyfin Similar Items API supports StartIndex, it should be added here.

🤖 Fix all issues with AI agents
In
`@emby-lib/src/test/kotlin/us/nineworlds/serenity/emby/server/api/EmbyAPIClientTest.kt`:
- Line 34: Replace the hardcoded LAN IP in the test by making the base URL
configurable and avoiding running against a developer-specific server: change
the call to client.updateBaseUrl(...) (in EmbyAPIClientTest) to read from an
environment variable (e.g., EMBY_BASE_URL) with a safe default like
"http://localhost:8096", and mark the integration test class or individual tests
with `@Ignore` (or a project test-category annotation) so they do not run in CI by
default.

In `@gradle/libs.versions.toml`:
- Line 23: The libs.versions.toml currently sets leanbackPagingVersion = "1.1.0"
but uses pagingVersion = "3.4.0", which is incompatible; update the
pagingVersion entries (all occurrences of the pagingVersion property in the
file) to use paging-runtime-ktx 3.3.6 (i.e., set pagingVersion = "3.3.6") so
leanback-paging 1.1.0 remains compatible, and ensure any dependency aliases that
reference pagingVersion are aligned to the new value.

In `@prompts/mvp_viewmodel_prompt.txt`:
- Around line 8-15: The instructions are inconsistent about base class (mentions
ViewModel on line 8 but requires AndroidViewModel(application) on line 14);
standardize on AndroidViewModel by replacing all references to 'ViewModel' with
'AndroidViewModel(application)' in the prompt and ensure the Presenter
instruction explicitly says "Extend AndroidViewModel(application) and implement
androidx.lifecycle.DefaultLifecycleObserver and Contract.Presenter"; also update
any example class signatures or steps that reference 'ViewModel' to use
'AndroidViewModel(application)' so all guidance is aligned.
- Around line 25-28: The current ViewModel factory uses an unsafe type-check
direction; update the factory for MyPresenter to either use the modern AndroidX
factory APIs (e.g., viewModelFactory { initializer { ... } } or
ViewModelProvider.Factory.from(...) ) to remove manual checks, or if
implementing ViewModelProvider.Factory manually, replace the unsafe
isAssignableFrom logic by checking the exact class equality (modelClass ==
MyPresenter::class.java) before returning presenterProvider.get() as T, or use
the correct direction MyPresenter::class.java.isAssignableFrom(modelClass);
update the factory object referenced by viewModels and ensure presenterProvider
and presenter are returned only when the type matches.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt`:
- Around line 22-28: The code calls factory.baseURL() concurrently inside
createEpisodeContentInfo when videos are processed in parallel (chunked +
async), which can race if implementations like JellyfinAPIClient mutate
baseUrl/accessToken/userId; to fix, call factory.baseURL() once on the calling
thread and pass the resulting immutable string into createEpisodeContentInfo
(and any other functions referencing factory.baseURL()) so each coroutine uses
the captured value, or alternatively make factory.baseURL() and the backing
fields thread-safe (synchronized/volatile/immutable) if you must keep concurrent
access.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainer.kt`:
- Around line 20-68: The code uses runBlocking inside createVideos which blocks
the calling thread; change createVideos to be a suspend fun and replace
runBlocking with coroutineScope (or simply make the mapping run in the current
coroutine), and remove unnecessary async/Dispatchers.Default overhead for
lightweight mapping — either map directly in the coroutine or wrap heavy CPU
work with withContext(Dispatchers.Default) for isolated CPU-bound work; update
all callers to call the new suspend createVideos (or document/bridge callers if
they cannot yet be suspend) and remove the runBlocking/async usage to avoid
thread blocking and coroutine dispatch overhead.
- Around line 39-40: MovieMediaContainer is assigning tick-based Longs directly
to Int fields (resumeOffset, duration); convert ticks to milliseconds first
using the existing convertTicksToMilliseconds(...) helper before casting to Int.
Update the assignments in MovieMediaContainer (where resumeOffset =
movie.viewOffset.toInt() and duration = movie.duration.toInt()) to call
convertTicksToMilliseconds(movie.viewOffset) and
convertTicksToMilliseconds(movie.duration) and then cast to Int, following the
pattern used in JellyfinMediaContainerAdaptor and MediaContainerAdaptor to avoid
truncation/overflow.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/core/paging/EpisodePagingSource.kt`:
- Around line 24-26: The catch block in EpisodePagingSource.kt (and the
equivalent in SimilarItemsPagingSource.kt) currently catches Exception which
swallows CancellationException; update the error handling in the load(...)
method(s) to rethrow CancellationException (e.g., if (ex is
CancellationException) throw ex) before returning LoadResult.Error, or restrict
the catch to more specific exceptions like IOException so cancellation
propagates correctly; modify the catch blocks in EpisodePagingSource.load and
SimilarItemsPagingSource.load accordingly.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/core/repository/VideoRepository.kt`:
- Around line 42-50: MovieMediaContainer.createVideos() currently uses
runBlocking and blocks threads when called from the suspend function
fetchSimilarItemsList; remove runBlocking and either (A) make createVideos() a
suspend function and implement its parallel chunking using coroutine primitives
(e.g., withContext(Dispatchers.IO) and coroutineScope/async/await or mapChunks
with mapConcurrent), or (B) change createVideos() to return a plain List
synchronously (no blocking) and move the chunked parallel processing into a new
suspend helper (e.g., fetchSimilarItemsList can call a suspend
processInChunks(...) that uses coroutineScope and async on Dispatchers.IO).
Update all call sites (like fetchSimilarItemsList and any uses similar to
EpisodeMediaContainer) to call the new suspend variant so no runBlocking
remains.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/fragments/mainmenu/MainMenuPresenter.kt`:
- Around line 57-66: The loading spinner is hidden immediately because
viewState.hideLoading() runs right after processesCategories returns while that
function launches child coroutines to fetch/paginate asynchronously; change the
flow so hideLoading() runs only after the first page is delivered — e.g., modify
processesCategories to either suspend until the first page is submitted (make it
a suspend function that awaits its child jobs or returns a Job/Deferred which
the caller can join/await), or add an onFirstPageLoaded callback/continuation
that you invoke where paging data is submitted (inside the paging
collector/submitData) and call viewState.hideLoading() there; update
presenterScope.launch to await the returned Job/Deferred or use the callback so
hideLoading() executes after first-page delivery.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/fragments/mainmenu/MainMenuView.kt`:
- Around line 21-22: The AddToEndStrategy is causing stale PagingData commands
to accumulate for updateCategories when populateMovieCategories creates new
collectLatest flows and galleryJob?.cancel() only stops active flows; ensure
clearCategories() is invoked before loading new categories in
populateMovieCategories (and any other movie/tv library reload paths) so queued
updateCategories entries are removed, or replace AddToEndStrategy for
updateCategories with a custom Moxy StateStrategy that removes previous
updateCategories commands when new ones are enqueued; update the code paths that
call populateMovieCategories and the view interface method updateCategories to
perform clearCategories() first or adopt the custom strategy to prevent
replaying single-use PagingData.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsFragment.kt`:
- Around line 250-252: updateSeasonEpisodes currently launches a lifecycleScope
coroutine that calls adapter.submitData without cancelling prior collectors,
which leaks coroutines; change it to track and cancel a previously stored Job
(e.g., seasonEpisodesJob) or use a dedicated CoroutineScope tied to that adapter
before launching a new coroutine that calls adapter.submitData, and do the same
pattern for addSimilarItems (e.g., similarItemsJob) so any existing Job is
cancelled prior to re-subscribing to new PagingData.
- Around line 62-68: areContentsTheSame in videoContentDiffCallback currently
uses oldItem == newItem which falls back to reference equality because
AbstractVideoContentInfo and its subclasses (MoviePosterInfo, EpisodePosterInfo)
don't override equals()/hashCode(); either implement content-aware equality by
overriding equals() and hashCode() in AbstractVideoContentInfo (comparing
identity fields like id, title, artwork, etc.) so existing comparisons work, or
change the areContentsTheSame implementation to explicitly compare the relevant
fields of VideoContentInfo (e.g., id(), title(), posterPath, metadata) to
determine content equality.

In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsMVPPresenter.kt`:
- Around line 99-103: The current call passes season.key.orEmpty() into
EpisodePagingSource which silently converts null to an empty string and can
cause failed/empty API calls; update the logic in DetailsMVPPresenter where the
PagingSource is created (the pagingSourceFactory that constructs
EpisodePagingSource) to first validate season.key and only instantiate
EpisodePagingSource when season.key is non-null and non-blank (e.g., skip or
return an empty/failed Flow for seasons with null/blank keys), otherwise
short-circuit to an appropriate empty paging/flow result so you don't pass ""
into EpisodePagingSource.

In
`@serenity-app/src/test/kotlin/us/nineworlds/serenity/core/repository/CategoryRepositoryTest.kt`:
- Around line 41-45: The tearDown() method in CategoryRepositoryTest is missing
a call to closeScope(), causing Toothpick DI scope state to leak between tests;
update the CategoryRepositoryTest.tearDown() to call closeScope() (matching
VideoRepositoryTest and SimilarItemsPagingSourceTest) after clearAllMocks() and
before/after Dispatchers.resetMain() to ensure the Toothpick scope is properly
cleaned up.
🧹 Nitpick comments (10)
serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainer.kt (1)

44-45: Minor: replaceFirst("/", "") may not handle all URL patterns correctly.

Line 44 uses movie.backgroundImageKey?.let { "$baseUrl${it.replaceFirst("/", "")}" } — if the key doesn't start with /, this will strip the first / from anywhere in the string. Consider using trimStart('/') or removePrefix("/") for safer prefix-only removal.

Suggested fix
-setBackgroundURL(movie.backgroundImageKey?.let { "$baseUrl${it.replaceFirst("/", "")}" } ?: baseImageResource)
-setImageURL(movie.thumbNailImageKey?.let { "$baseUrl${it.replaceFirst("/", "")}" } ?: "")
+setBackgroundURL(movie.backgroundImageKey?.let { "$baseUrl${it.removePrefix("/")}" } ?: baseImageResource)
+setImageURL(movie.thumbNailImageKey?.let { "$baseUrl${it.removePrefix("/")}" } ?: "")

Also apply the same fix at line 54:

-directPlayUrl = "$baseUrl${it.key.replaceFirst("/", "")}"
+directPlayUrl = "$baseUrl${it.key.removePrefix("/")}"
serenity-app/build.gradle.kts (1)

133-134: Paging dependencies added correctly.

The paging-runtime-ktx and leanback-paging dependencies are properly referenced from the version catalog. The codebase includes three PagingSource test files (EpisodePagingSourceTest, VideoCategoryPagingSourceTest, SimilarItemsPagingSourceTest) that manually construct LoadParams and assert results. While androidx.paging:paging-testing with TestPager could simplify this boilerplate in the future, it's not required—the current tests work effectively with the existing approach.

serenity-app/src/test/kotlin/us/nineworlds/serenity/core/paging/EpisodePagingSourceTest.kt (1)

100-111: Consider adding a test for Append load (non-zero key) to cover multi-page scenarios.

The current tests only exercise Refresh with key = 0. A test using PagingSource.LoadParams.Append with a non-zero key (e.g., key = 30) would validate that startIndex is correctly forwarded to the repository for subsequent pages, and that prevKey is set appropriately.

Also, the test name getRefreshKey returns correct key is slightly misleading since the expected value is null. A name like getRefreshKey returns null would be clearer.

serenity-app/src/main/kotlin/us/nineworlds/serenity/fragments/mainmenu/MainMenuPresenter.kt (1)

69-69: Typo in method name: processesCategories should be processCategories.

Proposed fix
-    private fun processesCategories(scope: CoroutineScope, categories: List<CategoryInfo>, itemId: String, type: String) {
+    private fun processCategories(scope: CoroutineScope, categories: List<CategoryInfo>, itemId: String, type: String) {

And update the call site on Line 60 accordingly.

serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt (1)

21-29: runBlocking inside a coroutine context blocks the IO thread unnecessarily.

createVideos() is called from VideoRepository.fetchEpisodes which already runs inside withContext(Dispatchers.IO). Using runBlocking here blocks the IO dispatcher thread while waiting for Dispatchers.Default work to complete, wasting the thread. Prefer making createVideos() a suspend function and using coroutineScope instead.

Suggested refactor
-    override fun createVideos(): List<VideoContentInfo> {
+    override suspend fun createVideos(): List<VideoContentInfo> {
         val videos = mc.videos ?: return emptyList()
         val baseUrl = factory.baseURL().orEmpty()
         var parentPosterURL: String? = null
         if (!mc.parentPosterURL.isNullOrEmpty() && !mc.parentPosterURL.contains("show")) {
             parentPosterURL = baseUrl + mc.parentPosterURL.substring(1)
         }

-        val createdVideos = runBlocking {
+        val createdVideos = coroutineScope {
             videos.chunked(25).map { chunk ->
                 async(Dispatchers.Default) {
                     chunk.map { episode ->
                         createEpisodeContentInfo(mc, baseUrl, parentPosterURL, episode)
                     }
                 }
             }.awaitAll().flatten()
         }

This would require the parent class's createVideos() signature to also be suspend, which may be a broader change. If that's not feasible now, consider at minimum documenting why runBlocking is used here.

As per coding guidelines, **/*.kt: Use Coroutines and Retrofit for asynchronous operations.

serenity-app/src/test/kotlin/us/nineworlds/serenity/core/paging/VideoCategoryPagingSourceTest.kt (1)

111-122: getRefreshKey test only covers the trivial null path.

With pages = emptyList(), closestPageToPosition(10) returns null, so the entire expression short-circuits to null. This doesn't exercise the actual key computation logic (prevKey + pageSize or nextKey - pageSize). Consider adding a test case with at least one Page in the state to validate the non-null branch.

serenity-app/src/main/kotlin/us/nineworlds/serenity/core/paging/EpisodePagingSource.kt (1)

13-27: Inconsistent error-handling pattern compared to VideoCategoryPagingSource.

EpisodePagingSource and SimilarItemsPagingSource use try-catch on the raw repository call, while VideoCategoryPagingSource uses a Result sealed class. This inconsistency means one path risks swallowing CancellationException while the other doesn't. Consider aligning all three paging sources to use the same pattern (preferably the Result sealed class approach which avoids catching exceptions at all).

serenity-app/src/test/kotlin/us/nineworlds/serenity/core/paging/SimilarItemsPagingSourceTest.kt (1)

71-87: Consider also testing a non-zero key to validate subsequent page loads.

The two load tests both use key = 0. Adding a test with key = 30 (simulating a second page load) would verify that startIndex is correctly forwarded and prevKey/nextKey are computed properly for non-initial pages.

serenity-app/src/test/kotlin/us/nineworlds/serenity/core/repository/VideoRepositoryTest.kt (1)

58-100: Tests verify delegation but not data transformation.

All three tests only assert the result type (isInstanceOf) and that the client was called with correct parameters. They don't verify that the repository correctly transforms the IMediaContainer response into the returned list. Consider adding assertions on the returned data (e.g., list size or content) to catch regressions in the mapping logic.

serenity-app/src/main/java/us/nineworlds/serenity/fragments/MainMenuVideoContentVerticalGridFragment.kt (1)

38-38: ConcurrentHashMap is unnecessary here — all access is on the main thread.

Fragment lifecycle callbacks and UI updates are main-thread-only. A plain HashMap or mutableMapOf() would be simpler and slightly more efficient.

client = EmbyAPIClient(context = ApplicationProvider.getApplicationContext())
// Update this for local testing of the client and make sure it works
client.updateBaseUrl("http://yourserver:yourport")
client.updateBaseUrl("http://192.168.68.97:8096")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hardcoded local IP address will break CI and other developers' environments.

"http://192.168.68.97:8096" is a developer-specific LAN address. This will cause all tests in this class to fail in CI or for anyone without this specific server. Consider reverting to "http://localhost:8096" or reading from an environment variable, and marking these integration tests with @Ignore (or a custom annotation/category) so they don't run in CI by default.

🤖 Prompt for AI Agents
In
`@emby-lib/src/test/kotlin/us/nineworlds/serenity/emby/server/api/EmbyAPIClientTest.kt`
at line 34, Replace the hardcoded LAN IP in the test by making the base URL
configurable and avoiding running against a developer-specific server: change
the call to client.updateBaseUrl(...) (in EmbyAPIClientTest) to read from an
environment variable (e.g., EMBY_BASE_URL) with a safe default like
"http://localhost:8096", and mark the integration test class or individual tests
with `@Ignore` (or a project test-category annotation) so they do not run in CI by
default.

androidPluginVersion = "8.13.1"
leanbackPreferenceVersion = "1.2.0"
leanbackVersion = "1.2.0"
leanbackPagingVersion = "1.1.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

androidx.leanback:leanback-paging latest version 2025

💡 Result:

The latest 2025 release of androidx.leanback:leanback-paging is 1.1.0, released April 23, 2025. [1][2]

Gradle

implementation("androidx.leanback:leanback-paging:1.1.0")

🌐 Web query:

androidx.paging:paging-runtime-ktx latest version 2025 compatibility

💡 Result:

  • Latest stable in 2025 (recommended for production): 3.3.6 (released Feb 12, 2025) for androidx.paging:paging-runtime-ktx. [1]
  • Latest 2025 prerelease on the 3.4 line: 3.4.0-beta01 (released Dec 17, 2025). [2]

2025 compatibility notes

  • Android minSdk
    • Paging 3.3.x: default minSdk 21 (implied by the 3.4 change below). [2]
    • Paging 3.4.0 (starting in 2025 alphas): default minSdk moved from 21 → 23 (notably called out in 3.4.0-alpha03, Aug 27, 2025). [2]

What to choose

  • If you need API 21–22 support in 2025: use 3.3.6. [1][2]
  • If your app is minSdk 23+ and you want the newer 3.4 line in 2025: use 3.4.0-beta01 (or a later 3.4 prerelease). [2]

Sources:
[1] Maven artifact page showing paging-runtime-ktx:3.3.6 date/version. (mvnrepository.com)
[2] Official AndroidX Paging release notes (3.4.0 timeline + minSdk 21→23 change). (developer.android.com)

Citations:


🌐 Web query:

androidx leanback-paging 1.1.0 paging 3.4.0 compatibility

💡 Result:

  • androidx.leanback:leanback-paging:1.1.0 is built to use Paging 3.1.0 (it was updated to Paging 3.1.0 in 1.1.0-alpha10, and 1.1.0 later moved that line to stable “with no changes”). [1]
  • Its published Maven metadata also shows a compile dependency on androidx.paging:paging-runtime:3.1.0. [2]
  • Paging 3.4.0 is a newer stable release (current stable listed as 3.4.0). [3]

What that means for “compatibility”:

  • There’s no official guarantee (in the Leanback-Paging 1.1.0 notes/metadata) that it’s tested against Paging 3.4.0; the declared target is 3.1.0. [1][2]
  • If you add paging-runtime:3.4.0 to your app, Gradle will typically resolve to 3.4.0 (newer wins), but you should treat that as “works if binary-compatible” and verify with builds + runtime testing (watch for NoSuchMethodError/ClassNotFoundException).

Sources: [1] AndroidX Leanback release notes, [2] MVNRepository dependency listing for leanback-paging 1.1.0, [3] AndroidX Paging release notes.


Use paging-runtime-ktx 3.3.6 instead of 3.4.0 to ensure compatibility with leanback-paging 1.1.0.

The leanbackPagingVersion = "1.1.0" is correct (latest, released April 23, 2025), but pagingVersion = "3.4.0" creates a compatibility issue. The leanback-paging 1.1.0 library was built against and tested with paging 3.1.0. Using paging 3.4.0—which raised the minSdk from 21 to 23 and introduces significant version drift—risks runtime failures without explicit verification. The recommended stable version for 2025 is paging 3.3.6 (Feb 12, 2025). If minSdk 23+ is confirmed, either use 3.3.6 or verify binary compatibility with 3.4.0 through testing.

Also applies to: 33-33, 79-79, 83-83

🤖 Prompt for AI Agents
In `@gradle/libs.versions.toml` at line 23, The libs.versions.toml currently sets
leanbackPagingVersion = "1.1.0" but uses pagingVersion = "3.4.0", which is
incompatible; update the pagingVersion entries (all occurrences of the
pagingVersion property in the file) to use paging-runtime-ktx 3.3.6 (i.e., set
pagingVersion = "3.3.6") so leanback-paging 1.1.0 remains compatible, and ensure
any dependency aliases that reference pagingVersion are aligned to the new
value.

Comment on lines +21 to +22
@StateStrategyType(AddToEndStrategy::class)
fun updateCategories(category: CategoryInfo, pagingData: PagingData<VideoCategory>)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how clearCategories interacts with the view state and if there's cleanup logic
echo "=== clearCategories implementations ==="
rg -n 'clearCategories' --type=kt --type=java -C5

echo ""
echo "=== AddToEndStrategy usage ==="
rg -n 'AddToEndStrategy' --type=kt --type=java -C2

Repository: NineWorlds/serenity-android

Length of output: 213


🏁 Script executed:

#!/bin/bash
echo "=== clearCategories implementations ==="
rg 'clearCategories' -C5

echo ""
echo "=== AddToEndStrategy usage ==="
rg 'AddToEndStrategy' -C2

echo ""
echo "=== MainMenuView.kt content ==="
find . -name 'MainMenuView.kt' -type f -exec cat -n {} \;

Repository: NineWorlds/serenity-android

Length of output: 8200


🏁 Script executed:

#!/bin/bash
echo "=== MainMenuPresenter.kt full content ==="
find . -name 'MainMenuPresenter.kt' -path '*/mainmenu/*' -type f -exec cat -n {} \;

echo ""
echo "=== MainMenuVideoContentVerticalGridFragment clearGallery and updateCategory ==="
rg 'fun clearGallery|fun updateCategory' -A 10

Repository: NineWorlds/serenity-android

Length of output: 7557


🏁 Script executed:

#!/bin/bash
echo "=== Check Moxy lifecycle and view state management ==="
rg 'presenterScope|InjectViewState|viewState' serenity-app/src/main/kotlin/us/nineworlds/serenity/fragments/mainmenu/ -A2 -B2

echo ""
echo "=== Check if there's any view state clearing on detach/destroy ==="
rg 'onDestroy|onDetach' serenity-app/src/main/kotlin/us/nineworlds/serenity/fragments/mainmenu/ -A5

Repository: NineWorlds/serenity-android

Length of output: 5830


🏁 Script executed:

#!/bin/bash
echo "=== MainMenuFragment implementation ==="
find . -name 'MainMenuFragment.kt' -type f -exec cat -n {} \;

Repository: NineWorlds/serenity-android

Length of output: 6558


AddToEndStrategy accumulation with PagingData when switching movie/tvshow categories without explicit clearing.

When populateMovieCategories is called repeatedly for movie or tvshow types, each call creates new collectLatest flows that queue updateCategories commands via AddToEndStrategy. Switching between different movie libraries calls galleryJob?.cancel(), which stops active flows, but the queued commands remain in Moxy's view state queue indefinitely. Since clearCategories() is only invoked for non-movie types, normal movie-to-movie switches never trigger view state cleanup.

On view detach/reattach (e.g., rotation), Moxy replays accumulated commands. Replaying stale PagingData objects is problematic since PagingData is single-use and cannot be collected twice. While the fragment's updateCategory silently skips missing adapters, this masks inefficiency and potential data consistency issues.

Call clearCategories() before loading new categories regardless of type, or use a custom Moxy StateStrategy that clears previous updateCategories entries when categories reload.

🤖 Prompt for AI Agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/fragments/mainmenu/MainMenuView.kt`
around lines 21 - 22, The AddToEndStrategy is causing stale PagingData commands
to accumulate for updateCategories when populateMovieCategories creates new
collectLatest flows and galleryJob?.cancel() only stops active flows; ensure
clearCategories() is invoked before loading new categories in
populateMovieCategories (and any other movie/tv library reload paths) so queued
updateCategories entries are removed, or replace AddToEndStrategy for
updateCategories with a custom Moxy StateStrategy that removes previous
updateCategories commands when new ones are enqueued; update the code paths that
call populateMovieCategories and the view interface method updateCategories to
perform clearCategories() first or adopt the custom strategy to prevent
replaying single-use PagingData.

Comment on lines 62 to 68
private val videoContentDiffCallback = object : DiffUtil.ItemCallback<VideoContentInfo>() {
override fun areItemsTheSame(oldItem: VideoContentInfo, newItem: VideoContentInfo): Boolean =
oldItem.id() == newItem.id()

override fun areContentsTheSame(oldItem: VideoContentInfo, newItem: VideoContentInfo): Boolean =
oldItem == newItem
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate the VideoContentInfo class
fd -t f -e kt | xargs rg -l 'class VideoContentInfo' | head -5

Repository: NineWorlds/serenity-android

Length of output: 174


🏁 Script executed:

# Search for VideoContentInfo class definition with context
rg -n 'class VideoContentInfo' -A 10 -B 2 --type=kt

Repository: NineWorlds/serenity-android

Length of output: 97


🏁 Script executed:

# Check if it's a data class and if it overrides equals
rg -n 'data class VideoContentInfo|override fun equals' -B 5 -A 5 --type=kt

Repository: NineWorlds/serenity-android

Length of output: 97


🏁 Script executed:

# Find all kotlin files that mention VideoContentInfo
fd -e kt -x rg -l 'class VideoContentInfo' {} \;

Repository: NineWorlds/serenity-android

Length of output: 176


🏁 Script executed:

# Search across all kotlin files for VideoContentInfo class definition
rg 'class VideoContentInfo' -A 15 --glob='*.kt'

Repository: NineWorlds/serenity-android

Length of output: 2417


🏁 Script executed:

# Also check if it's a data class
rg 'data class VideoContentInfo' -A 15 --glob='*.kt'

Repository: NineWorlds/serenity-android

Length of output: 53


🏁 Script executed:

# Search for all mentions of VideoContentInfo to find where it's defined
rg 'VideoContentInfo' --glob='*.kt' | grep -E '(class|interface|data class) VideoContentInfo' | head -10

Repository: NineWorlds/serenity-android

Length of output: 346


🏁 Script executed:

# Look for VideoContentInfo in all files more broadly
rg 'VideoContentInfo' --glob='*.kt' -l | head -20

Repository: NineWorlds/serenity-android

Length of output: 1897


🏁 Script executed:

# Check if it's defined in the DetailsFragment or nearby files
rg 'VideoContentInfo' --glob='*.kt' -B 2 -A 5 serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/ 2>/dev/null | head -100

Repository: NineWorlds/serenity-android

Length of output: 13383


🏁 Script executed:

# Read the VideoContentInfo interface
cat -n serenity-app/src/main/java/us/nineworlds/serenity/core/model/VideoContentInfo.kt

Repository: NineWorlds/serenity-android

Length of output: 1776


🏁 Script executed:

# Check ContentInfo interface it extends
rg 'interface ContentInfo' -A 20 --glob='*.kt'

Repository: NineWorlds/serenity-android

Length of output: 1800


🏁 Script executed:

# Check concrete implementations of VideoContentInfo
rg 'class.*:.*VideoContentInfo|class.*implements.*VideoContentInfo' -A 5 --glob='*.kt'

Repository: NineWorlds/serenity-android

Length of output: 1121


🏁 Script executed:

# Check AbstractVideoContentInfo implementation
cat -n serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt | head -150

Repository: NineWorlds/serenity-android

Length of output: 6098


🏁 Script executed:

# Look for concrete implementations that extend AbstractVideoContentInfo
rg 'class.*AbstractVideoContentInfo' -A 10 --glob='*.kt'

Repository: NineWorlds/serenity-android

Length of output: 4306


🏁 Script executed:

# Search for equals() implementations in AbstractVideoContentInfo and concrete classes
rg 'override fun equals|class.*VideoContentInfo.*{' -B 2 -A 8 --glob='*.kt' serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/

Repository: NineWorlds/serenity-android

Length of output: 226


🏁 Script executed:

# Search more carefully for equals in AbstractVideoContentInfo
grep -n 'override fun equals' serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt

Repository: NineWorlds/serenity-android

Length of output: 53


🏁 Script executed:

# Get the full size of AbstractVideoContentInfo to know how much to read
wc -l serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt

Repository: NineWorlds/serenity-android

Length of output: 168


🏁 Script executed:

# Check concrete implementations - look for MoviePosterInfo and EpisodePosterInfo
cat -n serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/MoviePosterInfo.kt

Repository: NineWorlds/serenity-android

Length of output: 401


🏁 Script executed:

# Read the rest of AbstractVideoContentInfo to see if equals() is defined
tail -70 serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt

Repository: NineWorlds/serenity-android

Length of output: 2274


🏁 Script executed:

# Check for EpisodePosterInfo
cat -n serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/EpisodePosterInfo.kt

Repository: NineWorlds/serenity-android

Length of output: 474


🏁 Script executed:

# Find all classes extending AbstractVideoContentInfo
rg 'extends AbstractVideoContentInfo|: AbstractVideoContentInfo' --glob='*.kt' -A 3

Repository: NineWorlds/serenity-android

Length of output: 626


areContentsTheSame uses reference equality instead of content equality — VideoContentInfo implementations don't override equals().

The oldItem == newItem comparison relies on equals(). Since AbstractVideoContentInfo and its concrete subclasses (MoviePosterInfo, EpisodePosterInfo) don't override equals(), this falls back to reference equality. Two distinct instances with identical content will not be considered equal, causing DiffUtil to skip the content comparison optimization and force full rebinds unnecessarily. Override equals() and hashCode() in AbstractVideoContentInfo or implement content-aware comparison in areContentsTheSame.

🤖 Prompt for AI Agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsFragment.kt`
around lines 62 - 68, areContentsTheSame in videoContentDiffCallback currently
uses oldItem == newItem which falls back to reference equality because
AbstractVideoContentInfo and its subclasses (MoviePosterInfo, EpisodePosterInfo)
don't override equals()/hashCode(); either implement content-aware equality by
overriding equals() and hashCode() in AbstractVideoContentInfo (comparing
identity fields like id, title, artwork, etc.) so existing comparisons work, or
change the areContentsTheSame implementation to explicitly compare the relevant
fields of VideoContentInfo (e.g., id(), title(), posterPath, metadata) to
determine content equality.

Comment on lines +41 to +45
@After
fun tearDown() {
clearAllMocks()
Dispatchers.resetMain()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing closeScope() in tearDown — inconsistent with other test classes.

Both VideoRepositoryTest and SimilarItemsPagingSourceTest call closeScope() during teardown to clean up the Toothpick DI scope. This test omits it, which could leak scope state between tests.

Proposed fix
     `@After`
     fun tearDown() {
         clearAllMocks()
         Dispatchers.resetMain()
+        closeScope()
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@After
fun tearDown() {
clearAllMocks()
Dispatchers.resetMain()
}
`@After`
fun tearDown() {
clearAllMocks()
Dispatchers.resetMain()
closeScope()
}
🤖 Prompt for AI Agents
In
`@serenity-app/src/test/kotlin/us/nineworlds/serenity/core/repository/CategoryRepositoryTest.kt`
around lines 41 - 45, The tearDown() method in CategoryRepositoryTest is missing
a call to closeScope(), causing Toothpick DI scope state to leak between tests;
update the CategoryRepositoryTest.tearDown() to call closeScope() (matching
VideoRepositoryTest and SimilarItemsPagingSourceTest) after clearAllMocks() and
before/after Dispatchers.resetMain() to ensure the Toothpick scope is properly
cleaned up.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
serenity-app/src/main/java/us/nineworlds/serenity/fragments/MainMenuVideoContentVerticalGridFragment.kt (1)

106-120: ⚠️ Potential issue | 🟡 Minor

Categories with null categoryDetail create orphaned rows that can never receive data.

When category.categoryDetail is null (Line 111), the adapter is not stored in adapterMap, but the ListRow is still added to videoContentAdapter (Line 118). This means updateCategory can never submit paging data to that row, leaving it permanently empty.

Either skip adding the row entirely when categoryDetail is null, or ensure it's always non-null upstream.

Proposed fix
         for (category in categories.categories) {
+            val detail = category.categoryDetail ?: continue
             val listContentRowAdapter = SerenityPagingDataAdapter(CategoryVideoPresenter(), videoCategoryDiffCallback)
-            category.categoryDetail?.let { detail ->
-                adapterMap[detail] = listContentRowAdapter
-            }
+            adapterMap[detail] = listContentRowAdapter
 
-            val header = HeaderItem(category.categoryDetail)
+            val header = HeaderItem(detail)
             val imageListRow = ListRow(header, listContentRowAdapter)
 
             videoContentAdapter.add(imageListRow)
🤖 Fix all issues with AI agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/core/paging/SimilarItemsPagingSource.kt`:
- Around line 11-25: In SimilarItemsPagingSource.load, the prevKey arithmetic
uses params.loadSize inconsistently causing incorrect prevKey after the larger
initialLoadSize; update prevKey to subtract params.loadSize (or a dedicated
pageSize property) from position so key math matches the load window — e.g., set
prevKey = if (position == 0) null else position - params.loadSize and keep
nextKey logic consistent (use params.loadSize or the fixed Pager pageSize value
for both prevKey and nextKey calculations) in the LoadResult.Page construction.
🧹 Nitpick comments (1)
serenity-app/src/main/java/us/nineworlds/serenity/fragments/MainMenuVideoContentVerticalGridFragment.kt (1)

38-38: ConcurrentHashMap is unnecessary — all access is on the main thread.

Both setupGallery, clearGallery, and updateCategory (via lifecycleScope) run on the main thread. A plain HashMap or mutableMapOf() would suffice and better communicates the intended threading model.

Fix failing test and address a few code review comments
around Paging.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt (1)

57-57: ⚠️ Potential issue | 🟡 Minor

Possible typo: "${baseUrl}:/resources/..." — the :// scheme separator looks wrong.

After switching to baseUrl (which likely already contains the scheme and host, e.g. http://host:port), the colon before /resources/show-fanart.jpg would produce a malformed URL like http://host:port:/resources/show-fanart.jpg. This should likely be "${baseUrl}/resources/show-fanart.jpg" (no colon).

🤖 Fix all issues with AI agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsFragment.kt`:
- Around line 259-273: addSimilarItems currently constructs a new ListRow and
SerenityPagingDataAdapter each call (creating duplicate "Similar" rows when
PagingData re-emits); instead, create and reuse the ListRow and items adapter
once (e.g., as lazy or class-level properties) and only call
itemsAdapter.submitData(pagingData) inside addSimilarItems, or guard the
detailsAdapter.add(similarRow) so it only adds the row if it's not already
present; update references to ListRow, SerenityPagingDataAdapter/ itemsAdapter,
similarRow, similarItemsJob, and addSimilarItems to use the single shared
instance and cancel/launch the job only to submitData.
🧹 Nitpick comments (3)
serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt (1)

47-51: Redundant null checks — isNullOrEmpty() already covers null.

On lines 47 and 50, episode.parentThumbNailImageKey != null && !episode.parentThumbNailImageKey.isNullOrEmpty() can be simplified to !episode.parentThumbNailImageKey.isNullOrEmpty(). Same for grandParentThumbNailImageKey.

Proposed simplification
-            if (episode.parentThumbNailImageKey != null && !episode.parentThumbNailImageKey.isNullOrEmpty()) {
+            if (!episode.parentThumbNailImageKey.isNullOrEmpty()) {
                 this.parentPosterURL = baseUrl + episode.parentThumbNailImageKey.substring(1)
             }
-            if (episode.grandParentThumbNailImageKey != null && !episode.grandParentThumbNailImageKey.isNullOrEmpty()) {
+            if (!episode.grandParentThumbNailImageKey.isNullOrEmpty()) {
                 grandParentPosterURL = baseUrl + episode.grandParentThumbNailImageKey.substring(1)
             }
serenity-app/src/main/kotlin/us/nineworlds/serenity/core/paging/SimilarItemsPagingSource.kt (1)

11-24: Minor formatting nit on Line 20.

The closing parenthesis of LoadResult.Page(...) is appended to the end of the nextKey line rather than on its own line. This makes the code harder to scan.

-                nextKey = if (data.isEmpty() || data.size < loadSize) null else position + loadSize            )
+                nextKey = if (data.isEmpty() || data.size < loadSize) null else position + loadSize
+            )
serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsMVPPresenter.kt (1)

92-114: Season key null-guard looks good; consider extracting duplicate PagingConfig.

The seasonsKey null check on Line 95 properly addresses the earlier concern about empty keys.

The identical PagingConfig(pageSize = 15, initialLoadSize = 30, enablePlaceholders = false) is repeated here and at Line 59. Consider extracting it to a companion constant to keep page-size decisions in one place.

Suggested refactor
 class DetailsMVPPresenter : MvpPresenter<DetailsView>() {
+
+    companion object {
+        private val DEFAULT_PAGING_CONFIG = PagingConfig(
+            pageSize = 15,
+            initialLoadSize = 30,
+            enablePlaceholders = false
+        )
+    }

Then reference DEFAULT_PAGING_CONFIG in both loadSimilarItems and updateSeries.

Comment on lines 259 to 273
override fun addSimilarItems(pagingData: PagingData<VideoContentInfo>) {
classPresenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter())
val itemsAdapter = ArrayObjectAdapter(VideoContentInfoPresenter())
val itemsAdapter = SerenityPagingDataAdapter(VideoContentInfoPresenter(), videoContentDiffCallback)

val header = HeaderItem("Similar")
itemsAdapter.addAll(0, videoInfo)
val similarRow = ListRow(header, itemsAdapter)

val detailsAdapter = adapter as ArrayObjectAdapter
detailsAdapter.add(similarRow)

similarItemsJob?.cancel()
similarItemsJob = viewLifecycleOwner.lifecycleScope.launch {
itemsAdapter.submitData(pagingData)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

addSimilarItems creates a new row and adapter on every call.

If collectLatest in the presenter re-emits PagingData (e.g., on invalidation), this method is called again, adding a duplicate "Similar" row. Consider creating the row/adapter once (e.g., lazily or in setup) and only calling submitData on subsequent invocations.

🤖 Prompt for AI Agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsFragment.kt`
around lines 259 - 273, addSimilarItems currently constructs a new ListRow and
SerenityPagingDataAdapter each call (creating duplicate "Similar" rows when
PagingData re-emits); instead, create and reuse the ListRow and items adapter
once (e.g., as lazy or class-level properties) and only call
itemsAdapter.submitData(pagingData) inside addSimilarItems, or guard the
detailsAdapter.add(similarRow) so it only adds the row if it's not already
present; update references to ListRow, SerenityPagingDataAdapter/ itemsAdapter,
similarRow, similarItemsJob, and addSimilarItems to use the single shared
instance and cancel/launch the job only to submitData.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsFragment.kt (1)

269-271: ⚠️ Potential issue | 🔴 Critical

addSimilarSeries throws TODO — this will crash at runtime when viewing similar items for a TV show.

DetailsMVPPresenter.loadSimilarItems calls viewState.addSimilarSeries(...) for the Types.SERIES path (Line 81 of the presenter). This unimplemented method will throw NotImplementedError at runtime.

Either implement the method or guard the call site in the presenter to avoid this path until the implementation is ready.

🤖 Fix all issues with AI agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsMVPPresenter.kt`:
- Around line 51-84: The SERIES branch in DetailsMVPPresenter.loadSimilarItems
calls viewState.addSimilarSeries(...) which currently throws a TODO in
DetailsFragment; update DetailsFragment to implement addSimilarSeries to accept
the list produced by SeriesMediaContainer(result).createSeries(), or modify
DetailsMVPPresenter to avoid calling addSimilarSeries until implemented (e.g.,
route SERIES results to the same paging UI or a safe no-op method). Locate
addSimilarSeries in DetailsFragment and implement the UI update logic (adapter
update or view binding) to safely handle the series list returned from
repository.fetchSimilarItems/SeriesMediaContainer.createSeries().
🧹 Nitpick comments (1)
serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsMVPPresenter.kt (1)

93-111: Each season spawns an independent Pager + collectLatest coroutine — may trigger many concurrent API calls.

For a show with many seasons, this forEach loop creates N paging flows that all start loading immediately. Each initial load (size 30) will fire concurrently, which could overwhelm the backend or the device on lower-end hardware (like the TCL TV mentioned in the PR).

Consider staggering the loads (e.g., only paginate the currently visible/selected season) or limiting concurrency with a semaphore/dispatcher. This isn't blocking but worth considering for reliability on constrained devices.

Comment on lines 51 to 84
fun loadSimilarItems(itemId: String, type: String) {
presenterScope.launch {
val itemType = when (type) {
"tvshows" -> Types.SERIES
else -> Types.MOVIES
}

val result = repository.fetchSimilarItems(itemId, itemType)
when (itemType) {
Types.MOVIES -> {
val videos = MovieMediaContainer(result)
.createVideos()
.filterNot { item ->
item.getType() == Types.SERIES
}
val itemType = when (type) {
"tvshows" -> Types.SERIES
else -> Types.MOVIES
}

viewState.addSimilarItems(videos)
if (itemType == Types.MOVIES) {
val pagingFlow = Pager(
config = PagingConfig(
pageSize = 15,
initialLoadSize = 30,
enablePlaceholders = false
),
pagingSourceFactory = {
SimilarItemsPagingSource(repository, itemId, itemType)
}
).flow
.map { pagingData ->
pagingData.filter { item -> item.getType() != Types.SERIES }
}
.cachedIn(presenterScope)

else -> viewState.addSimilarSeries(SeriesMediaContainer(result).createSeries())
presenterScope.launch {
pagingFlow.collectLatest { pagingData ->
viewState.addSimilarItems(pagingData)
}
}
} else {
presenterScope.launch {
val result = repository.fetchSimilarItems(itemId, itemType)
viewState.addSimilarSeries(SeriesMediaContainer(result).createSeries())
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Paging flow for similar MOVIES with filtering and caching is well-wired.

The Pagerflowmap { filter }cachedIncollectLatest pipeline follows the standard Paging 3 pattern correctly. The fallback to direct fetch for SERIES is a reasonable interim approach.

One note: addSimilarSeries in DetailsFragment throws TODO("Not yet implemented") (Line 270 of DetailsFragment.kt), so the SERIES path on Line 81 will crash at runtime.

#!/bin/bash
# Verify addSimilarSeries implementation
rg -n 'addSimilarSeries' --type=kt -C 3
🤖 Prompt for AI Agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsMVPPresenter.kt`
around lines 51 - 84, The SERIES branch in DetailsMVPPresenter.loadSimilarItems
calls viewState.addSimilarSeries(...) which currently throws a TODO in
DetailsFragment; update DetailsFragment to implement addSimilarSeries to accept
the list produced by SeriesMediaContainer(result).createSeries(), or modify
DetailsMVPPresenter to avoid calling addSimilarSeries until implemented (e.g.,
route SERIES results to the same paging UI or a safe no-op method). Locate
addSimilarSeries in DetailsFragment and implement the UI update logic (adapter
update or view binding) to safely handle the series list returned from
repository.fetchSimilarItems/SeriesMediaContainer.createSeries().

@codecov
Copy link

codecov bot commented Feb 7, 2026

Codecov Report

❌ Patch coverage is 68.07512% with 68 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.23%. Comparing base (61d765f) to head (2c8161a).
⚠️ Report is 12 commits behind head on develop.

Files with missing lines Patch % Lines
...gments/MainMenuVideoContentVerticalGridFragment.kt 0.00% 17 Missing ⚠️
...ty/ui/activity/leanback/details/DetailsFragment.kt 0.00% 10 Missing ⚠️
...s/serenity/fragments/mainmenu/MainMenuPresenter.kt 71.42% 5 Missing and 3 partials ⚠️
...ds/serenity/core/model/impl/MovieMediaContainer.kt 82.50% 3 Missing and 4 partials ⚠️
...worlds/serenity/core/paging/EpisodePagingSource.kt 60.00% 1 Missing and 5 partials ⚠️
.../serenity/core/paging/VideoCategoryPagingSource.kt 60.00% 1 Missing and 5 partials ⚠️
.../ui/leanback/adapters/SerenityPagingDataAdapter.kt 76.19% 4 Missing and 1 partial ⚠️
...i/activity/leanback/details/DetailsMVPPresenter.kt 87.50% 1 Missing and 3 partials ⚠️
...s/serenity/core/paging/SimilarItemsPagingSource.kt 80.00% 0 Missing and 3 partials ⚠️
.../nineworlds/serenity/fragments/MainMenuFragment.kt 0.00% 1 Missing ⚠️
... and 1 more
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #516      +/-   ##
=============================================
+ Coverage      48.63%   53.23%   +4.59%     
- Complexity       474      518      +44     
=============================================
  Files             65       69       +4     
  Lines           2204     2303      +99     
  Branches         312      329      +17     
=============================================
+ Hits            1072     1226     +154     
+ Misses          1001      911      -90     
- Partials         131      166      +35     
Flag Coverage Δ
unittests 53.23% <68.07%> (+4.59%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/core/paging/VideoCategoryPagingSource.kt`:
- Around line 19-23: The computed prevKey in VideoCategoryPagingSource's
LoadResult.Page can go negative (position - loadSize) after a refresh; update
the prevKey calculation to clamp to zero (e.g., use coerceAtLeast(0) or
Math.max(0, position - loadSize)) so the repository never receives a negative
startIndex, leaving the nextKey logic unchanged.
🧹 Nitpick comments (4)
serenity-app/src/main/kotlin/us/nineworlds/serenity/ui/leanback/adapters/SerenityPagingDataAdapter.kt (1)

50-59: Redundant double-guard in get().

The explicit bounds check (Line 51) already returns null for invalid positions, making the try-catch on IndexOutOfBoundsException unreachable under normal conditions. Consider keeping only one guard to reduce noise. The bounds check alone is sufficient.

♻️ Simplify get()
     override fun get(position: Int): Any? {
         if (position < 0 || position >= size()) {
             return null
         }
-        return try {
-            internalAdapter.get(position)
-        } catch (e: IndexOutOfBoundsException) {
-            null
-        }
+        return internalAdapter.get(position)
     }
serenity-app/src/test/kotlin/us/nineworlds/serenity/ui/leanback/adapters/SerenityPagingDataAdapterTest.kt (2)

26-28: @Config annotation has no parameters.

This is a no-op — Robolectric will use its defaults. If that's intentional, consider removing it to reduce noise, or specify the SDK level explicitly (e.g., @Config(sdk = [33])) for deterministic test behavior across environments.


80-111: Observer test could be more precise about which notification method fired.

Currently all four observer callbacks set the same notified flag, so the test confirms something was notified but not what. For a bridge adapter this is acceptable, but if you want stronger guarantees you could track which method was called (e.g., onItemRangeInserted is the expected one for fresh data).

♻️ More precise observer assertion
-        var notified = false
+        var insertedCount = 0
         val observer = object : ObjectAdapter.DataObserver() {
-            override fun onChanged() {
-                notified = true
-            }
-
-            override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
-                notified = true
-            }
-
             override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
-                notified = true
-            }
-
-            override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
-                notified = true
+                insertedCount += itemCount
             }
         }
         adapter.registerObserver(observer)
@@ // ... submitData ...
-        assertThat(notified).isTrue()
+        assertThat(insertedCount).isEqualTo(1)
serenity-app/src/test/kotlin/us/nineworlds/serenity/ui/activity/leanback/details/DetailsMVPPresenterTest.kt (1)

44-51: Consider using the same UnconfinedTestDispatcher instance for setMain and runTest.

Dispatchers.setMain(Dispatchers.Unconfined) and runTest(UnconfinedTestDispatcher()) use separate dispatchers. If the presenter launches coroutines on Dispatchers.Main, they won't be governed by runTest's test scope, so advanceUntilIdle() won't control them. It works here because both are unconfined (eager execution), but the recommended pattern is:

private val testDispatcher = UnconfinedTestDispatcher()

`@Before`
fun setUp() {
    Dispatchers.setMain(testDispatcher)
    // ...
}

// then: runTest(testDispatcher) { ... }

This ensures runTest properly tracks all coroutines launched on Main.

…g/VideoCategoryPagingSource.kt

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@serenity-app/src/main/kotlin/us/nineworlds/serenity/core/paging/VideoCategoryPagingSource.kt`:
- Around line 16-22: The Result.Success branch in VideoCategoryPagingSource
(when handling repository.fetchItemsByCategory(...)) currently extracts
result.data but fails to return a LoadResult.Page; update the is Result.Success
block to return LoadResult.Page(items = data, prevKey = if (position - loadSize
<= 0) null else position - loadSize, nextKey = if (data.isEmpty()) null else
position + data.size) so the when expression yields a LoadResult<Int,
VideoContentInfo> and prevKey is clamped to avoid negative offsets.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Feb 8, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
10.2% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@kingargyle kingargyle merged commit dec7ce5 into develop Feb 8, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Paging Library and paging support

1 participant