Skip to content

[TV] Load auth-specific discover feeds on TV home - #5722

Open
sztomek wants to merge 4 commits into
feat/tv-search-discover-feedfrom
feat/tv-home-auth-feeds
Open

[TV] Load auth-specific discover feeds on TV home#5722
sztomek wants to merge 4 commits into
feat/tv-search-discover-feedfrom
feat/tv-home-auth-feeds

Conversation

@sztomek

@sztomek sztomek commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description

Stacked on #5720. Switches the TV Home screen to Apple TV's auth-specific discover feeds.

Apple TV (DiscoverServerHandler.DiscoverType) serves a dedicated feed per sign-in state; HomeView switches on userState.isLoggedIn. Android Home used a single generic content_v3.json. This aligns it:

Tab State URL (prod)
Home Signed in https://static.pocketcasts.com/discover/tv/content_v3_logged_in.json
Home Signed out https://static.pocketcasts.com/discover/tv/content_v3_logged_out.json

(Staging/debug: static.pocketcasts.net.) TvDiscoverFeedLoader.load(isLoggedIn) now picks the feed from syncManager.isLoggedIn(), matching iOS.

Model change (why it's needed)

The logged-in/out feeds contain rows that omit the source and expanded_style keys — the empty "Up Next"/"New Releases" placeholders (which iOS fills from local data) and banner rows. Android's DiscoverRow.source/expandedStyle were non-null with no default, so Moshi threw on those rows and the whole feed failed to parse. They now default (source = "", expandedStyle = ExpandedStyle.PlainList()) when the key is absent. Existing feeds always send both keys, so behavior there is unchanged; this avoided making source nullable across the shared model (which the phone Discover feature relies on). ListRepository.getDiscoverFeed() (→ content_v3.json) stays for the phone Discover feature.

Rendering behavior (known divergence from iOS)

Android keeps building its own local rows (Keep Listening / Up Next / New Releases) and prepending them; the feed's blank-source placeholder rows and banner rows are dropped (if (row.source.isBlank()) return null + unknown type → null). So the row set/order differs from iOS's inline-fill, even though the feed file now matches. Full inline-fill + banner support would be a further follow-up.

Fixes PCDROID-717 https://linear.app/a8c/issue/PCDROID-717/use-appropriate-feed-depending-on-signed-in-state

Testing Instructions

  1. ./gradlew :tv:installDebug, open the TV app on the Home tab.
  2. Signed in: Home shows Keep Listening / Up Next local rows + the logged-in discover rows (Made for TV, Trending, …).
  3. Signed out: Home shows the logged-out discover rows.
  4. Verify via adb logcat | grep content_v3:
    • Signed in → content_v3_logged_in.json
    • Signed out → content_v3_logged_out.json

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply)
  • I have considered whether it makes sense to add tests for my changes
  • All strings that need to be localized are in localization
  • Any jetpack compose components I added or changed are covered by compose previews
  • I have updated the Event Horizon schema for analytics changes

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 3m 31s —— View job


Code review

  • Read PR diff (origin/feat/tv-search-discover-feed...HEAD)
  • Review DiscoverModel default-value change and its blast radius
  • Review ListWebService / ListRepository additions
  • Review TvDiscoverFeedLoader changes
  • Review test coverage
  • Post review (3 inline comments)

The change is small and well-scoped, and the PR body does a good job documenting the trade-offs. Endpoint selection is sound: TvHomeViewModel already collects syncManager.isLoggedInObservable and calls load() on change (TvHomeViewModel.kt:66-71), so signing in/out swaps the feed URL correctly rather than serving a stale one. Nothing blocking; the notes below are mostly about the shared-model change reaching further than the TV app.

Findings

1. loadCategories can now issue a request with a blank URLTvDiscoverFeedLoader.kt:40

The new isBlank() guard is in loadRow, but loadCategories reads source directly and its elvis only handles "no categories row at all". With source now defaulting to "", a categories row missing the key reaches getCategoriesList(""), and Retrofit's @Url resolves "" against the base URL — a GET https://static.pocketcasts.com/. The surrounding try/catch downgrades it to emptyList(), so it's degradation rather than a crash, but it costs a request and logs a misleading failure. ?.source?.takeIf(String::isNotBlank) closes it. Fix this →

2. source = "" relaxes an invariant ~18 phone call sites depend onDiscoverModel.kt:66

DiscoverAdapter.kt:806/824/841/877/933/978/1051, CuratedPodcastsCrawler.kt:29 and PodcastGridListFragment.kt:87 all pass row.source straight into a request or a fragment arg. Previously a source-less row failed loudly at parse time; now it silently becomes "" and fails further downstream with a much less obvious error. The TV loader guards it, phone doesn't.

You explicitly rejected nullable, which is reasonable. A cheaper middle ground within this PR's scope: filter blank sources once in transformWithRegion (DiscoverModel.kt:54) so every consumer inherits the guarantee. Failing that, a KDoc noting that blank means "placeholder row the client fills locally" would at least make the contract discoverable.

Two smaller notes on the same change: Moshi applies defaults only for an absent key — an explicit "source": null still throws, so the tolerance is narrower than it reads. And the expandedStyle default is a non-issue: ExpandedStyle.fromString already falls back to PlainList() for unrecognised values (ExpandedStyle.kt:22), so the default matches existing behaviour exactly.

3. Neither behaviour change is testedTvHomeViewModelTest.kt

The test diff is a mock rename. Endpoint selection is implicitly covered (an unstubbed non-null suspend returns nullError state → test fails), but two gaps remain: the blank-source drop (trivial to add with the existing row() helper — sketch in the inline comment), and the Moshi defaults themselves. The latter is the one I'd prioritise: the model change exists solely to stop the TV feed failing to parse, and nothing pins that contract, so re-tightening the field would break TV Home at runtime with a green test suite.

Nits

  • TvDiscoverFeedLoader.kt:28 is 122 chars, over the 120 in AGENTS.md. .editorconfig sets max_line_length=off, so spotless won't flag it.
  • Blank-source rows are dropped with no log — a Timber.d would help the next time the feed shape shifts.
  • getLoggedInDiscoverFeed/getLoggedOutDiscoverFeed are TV-only (/discover/android/content_v3_logged_in.json doesn't exist). Worth a KDoc, or collapsing to one getHomeDiscoverFeed(isLoggedIn: Boolean) so the caller doesn't branch.

No security or performance concerns — both endpoints are unauthenticated static JSON on the same client, and the feed is fetched once per login-state change.

--- · Branch: feat/tv-home-auth-feeds

}

private suspend fun loadRow(row: DiscoverRow): TvDiscoverRow? {
if (row.source.isBlank()) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This guard covers loadRow, but loadCategories (line 40) reads source off the layout without the same check:

val source = discover.layout.firstOrNull { it.type is ListType.Categories }?.source ?: return emptyList()

The elvis only handles "no categories row". Now that source defaults to "", a categories row missing the key falls through as a blank string into listRepository.getCategoriesList(""), and Retrofit's @Url resolves "" against the base URL — i.e. a GET https://static.pocketcasts.com/. The surrounding try/catch means it degrades to emptyList() rather than crashing, but it's a wasted request and a misleading "Failed to load TV search categories" log instead of a clean no-op.

Suggested change
if (row.source.isBlank()) return null
if (row.source.isBlank()) {
Timber.d("Skipping discover row without a source: ${row.id ?: row.title}")
return null
}

...and separately at line 40:

val source = discover.layout.firstOrNull { it.type is ListType.Categories }
    ?.source?.takeIf(String::isNotBlank) ?: return emptyList()

The Timber.d is optional but worth it — right now placeholder rows vanish with no trace, which will be annoying the next time the feed shape changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both guards are in place. loadRow returns null on row.source.isBlank(), and loadCategories now resolves the source with ?.source?.takeIf(String::isNotBlank) ?: return emptyList(), so a categories row present but missing its source no longer falls through as "" into getCategoriesList("") (which would resolve @Url against the base URL). Both degrade to a clean no-op instead of a wasted request + misleading log.

@Json(name = "expanded_top_item_label") override val expandedTopItemLabel: String?,
@Json(name = "title") override val title: String,
@Json(name = "source") override val source: String,
@Json(name = "source") override val source: String = "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two things worth recording here.

1. The default only applies when the key is absent. Moshi's generated adapter uses the default for a missing key, but an explicit "source": null still throws Non-null value 'source' was null. That matches what you describe in the PR body (the keys are omitted), just noting the invariant is narrower than "tolerates missing source".

2. This relaxes a shared invariant that ~18 phone call sites rely on. DiscoverAdapter.kt:806/824/841/877/933/978/1051, CuratedPodcastsCrawler.kt:29, and PodcastGridListFragment.kt:87 all feed row.source straight into a request or a fragment arg. Before this change, a feed row without source failed loudly at parse time; now it silently becomes "" and turns into a request against the base URL that fails with a confusing error further downstream. The TV loader has an explicit blank guard — nothing on phone does.

You explicitly considered nullable and rejected it, which is fair. A cheaper middle ground that keeps this PR's scope: filter blank sources once in transformWithRegion (DiscoverModel.kt:54), so every consumer including phone gets the guarantee, and the TV-side isBlank() check becomes belt-and-braces:

fun List<DiscoverRow>.transformWithRegion(...) = this
    .filter { it.regions.contains(region.code) && it.source.isNotBlank() }
    .map { it.transformWithReplacements(replacements, resources) }

If you'd rather not, a short KDoc on the property explaining that blank means "placeholder row the client fills locally" would at least make the contract discoverable.

FWIW the expandedStyle default on line 63 is a non-issue — ExpandedStyle.fromString already returns PlainList() for unrecognised values (ExpandedStyle.kt:22), so the new default is exactly the existing fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Went with the KDoc rather than filtering in transformWithRegion. Documented the contract on the source property directly ("Blank when the feed omits the key: a placeholder row the client fills locally. Consumers must skip blank sources."), which makes the invariant discoverable at all ~18 phone call sites.

I deliberately did not add it.source.isNotBlank() to transformWithRegion: that's the shared phone Discover path, and silently dropping a blank-source row there would change phone behaviour in a TV-scoped PR (a blank source on phone currently fails loudly downstream, which for the phone feed is arguably the signal you want, not a silent disappearance). The TV loader owns its own blank-source skip, so the guarantee is enforced where the placeholder rows actually occur. And noted your point 1 — the default only kicks in for an absent key; an explicit "source": null still throws — which the KDoc wording ("omits the key") reflects.

fun `keep listening row shows first up next episode even when signed out`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
whenever(listRepository.getDiscoverFeed()).thenReturn(discover())
whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(discover())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The test diff is a mock rename only, so neither of the two actual behaviour changes in this PR is covered.

One of them is implicitly covered, to be fair: because getLoggedInDiscoverFeed/getLoggedOutDiscoverFeed return a non-null Discover, an unstubbed call returns null and the ViewModel lands in Error, so these tests would fail if the wrong endpoint were picked. Good enough for endpoint selection.

The two gaps that aren't covered:

Blank-source rows are dropped — the new loadRow guard. Trivial with the existing row() helper:

@Test
fun `rows without a source are dropped`() = runTest {
    whenever(syncManager.isLoggedIn()).thenReturn(false)
    whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
        discover(
            row(id = "up-next-placeholder",, source = ""),
            row(id = "trending",, source = "https://lists/trending.json"),
        ),
    )
    whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any()))
        .thenReturn(listFeed(podcast("podcast-1")))

    createViewModel().uiState.test {
        val state = awaitItem() as TvHomeUiState.Ready
        assertEquals(listOf("trending"), state.rows.map { it.id })
        verify(listRepository, never()).getListFeed(eq(""), any())
    }
}

The Moshi defaults actually parse — this is the whole reason DiscoverModel changed, and nothing guards it. A regression here (someone re-tightening the field, or the adapter changing) breaks the TV Home feed entirely at runtime with no test failure. There's no existing parse test in modules/services/servers to extend, but a small one asserting that a DiscoverRow JSON with source/expanded_style omitted parses to "" / PlainList would pin the contract that this PR depends on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both gaps are covered now.

  • Blank-source rows droppedrows without a source are dropped in TvHomeViewModelTest asserts the placeholder row is gone and getListFeed("") is never called.
  • Moshi defaults parse — added DiscoverModelTest in modules/services/servers (new .../servers/model/ test package): it parses a DiscoverRow JSON with source and expanded_style omitted through the real ListType/DisplayStyle/ExpandedStyle adapters and asserts source == "" and expandedStyle is ExpandedStyle.PlainList. That pins the contract this PR depends on, so re-tightening the field breaks a test instead of the TV Home feed at runtime.

@sztomek sztomek changed the title Load auth-specific discover feeds on TV home [TV] Load auth-specific discover feeds on TV home Aug 11, 2026
@sztomek sztomek added [Type] Feature Adding a new feature. [Area] TV labels Aug 11, 2026
@sztomek sztomek added this to the 8.19 milestone Aug 11, 2026
@sztomek
sztomek marked this pull request as ready for review August 11, 2026 18:26
@sztomek
sztomek requested a review from a team as a code owner August 11, 2026 18:26
@sztomek
sztomek requested review from geekygecko and removed request for a team August 11, 2026 18:26
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 1m 37s —— View job


Code review

  • Read PR diff (origin/feat/tv-search-discover-feed...HEAD)
  • Re-verify prior findings still apply
  • Check DiscoverModel default blast radius on phone
  • Check TvDiscoverFeedLoader / ListRepository / ListWebService
  • Check test coverage
  • Post review

Re-reviewed on ready_for_review. The diff is unchanged since my earlier pass, so the findings below still stand. Nothing blocking — the change is small and correctly wired.

What's solid: endpoint selection is race-free in the way that matters. TvHomeViewModel.load() cancels the in-flight job and loadRows() reads syncManager.isLoggedIn() once, using the same value for both the endpoint choice and the authenticated row filter — so the feed and the filter can't disagree. The isLoggedInObservable collector (TvHomeViewModel.kt:66-71) triggers a reload on sign-in/out, so no stale feed. platform resolves to tv via RepositoryProviderModule.kt:71-82, and getDiscoverFeed() (→ content_v3.json) is untouched for phone Discover / BrowseTreeProvider / onboarding.

Findings

1. loadCategories can now issue a request with a blank URLTvDiscoverFeedLoader.kt:40

The new guard is in loadRow (line 71); loadCategories reads source directly and its elvis only handles "no categories row at all". With source now defaulting to "", a categories row missing the key reaches getCategoriesList(""), and Retrofit's @Url resolves "" against the base URL — a GET https://static.pocketcasts.com/. The surrounding try/catch downgrades it to emptyList(), so it's degradation not a crash, but it costs a request and logs a misleading "Failed to load TV search categories".

val source = discover.layout.firstOrNull { it.type is ListType.Categories }
    ?.source?.takeIf(String::isNotBlank) ?: return emptyList()

Fix this →

2. source = "" relaxes an invariant phone call sites depend onDiscoverModel.kt:66

DiscoverAdapter.kt:806/824/841/877/933/978/1051, CuratedPodcastsCrawler.kt:29 and PodcastGridListFragment.kt:87 all pass row.source straight into a request or a fragment arg. Previously a source-less row failed loudly at parse time; now it silently becomes "" and fails further downstream with a much less obvious error. The TV loader guards it; phone doesn't.

You explicitly rejected nullable, which is reasonable. A cheaper middle ground inside this PR's scope — filter once in transformWithRegion (DiscoverModel.kt:54-56) so every consumer inherits the guarantee and the TV-side check becomes belt-and-braces:

return this.filter { it.regions.contains(region.code) && it.source.isNotBlank() }
    .map { it.transformWithReplacements(replacements, resources) }

Failing that, a KDoc noting blank means "placeholder row the client fills locally" would at least make the contract discoverable.

Two sub-notes on the same change:

  • Moshi's codegen adapter applies a default only for an absent key. An explicit "source": null still throws Non-null value 'source' was null, so the tolerance is narrower than it reads.
  • The expandedStyle default is a non-issue: ExpandedStyle.fromString already returns PlainList() for anything unrecognised (ExpandedStyle.kt:20), so the new default matches existing behaviour exactly.

3. Neither behaviour change is testedTvHomeViewModelTest.kt

The test diff is a mock rename only. Endpoint selection is implicitly covered (an unstubbed non-null suspend returns nullError state → test fails), which is good enough there. Two real gaps:

  • The blank-source drop — trivial with the existing row() helper, asserting the row is dropped and getListFeed("") is never called.
  • The Moshi defaults themselves — this is the one I'd prioritise. The model change exists solely to stop the TV feed failing to parse, and nothing pins that contract; re-tightening either field would break TV Home at runtime with a fully green suite. A small parse test in modules/services/servers asserting a DiscoverRow JSON with source/expanded_style omitted yields "" / PlainList would cover it.

Nits

  • TvDiscoverFeedLoader.kt:28 is 122 chars, over the 120 in AGENTS.md. .editorconfig sets max_line_length=off, so spotless won't catch it.
  • Blank-source rows are dropped with no log — a Timber.d would help the next time the feed shape shifts.
  • getLoggedInDiscoverFeed/getLoggedOutDiscoverFeed are TV-only (/discover/android/content_v3_logged_in.json doesn't exist), but they sit on the shared ListRepository with no hint of that. Worth a KDoc, or collapsing into one getHomeDiscoverFeed(isLoggedIn: Boolean) so the caller doesn't branch.

No security or performance concerns: both endpoints are unauthenticated static JSON on the same client, fetched once per login-state change.


· Branch: feat/tv-home-auth-feeds

@sztomek
sztomek force-pushed the feat/tv-search-discover-feed branch from cabb1c1 to 78bccff Compare August 11, 2026 18:40
@sztomek
sztomek force-pushed the feat/tv-home-auth-feeds branch from 937037a to a9ef5f4 Compare August 11, 2026 18:43
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 1m 43s —— View job


Code review

  • Read updated PR diff (origin/feat/tv-search-discover-feed...HEAD)
  • Verify fixes for prior findings
  • Check remaining test coverage
  • Post review

Re-reviewed after a9ef5f4 ("Address PR comments"). Two of my three findings are resolved:

  • loadCategories blank-URL request — fixed with ?.source?.takeIf(String::isNotBlank) (TvDiscoverFeedLoader.kt:47-48). Correct — the elvis now covers both "no categories row" and "row with a blank source".
  • Blank-source rows are dropped — covered by the new rows without a source are dropped test (TvHomeViewModelTest.kt:182), which asserts both the row set and that getListFeed("") is never called. Exactly the shape I'd want.

The endpoint wiring is unchanged and still looks right: load() picks the feed from the same isLoggedIn value the authenticated row filter uses, and the isLoggedInObservable collector reloads on sign-in/out, so the feed can never go stale or disagree with the filter.

Remaining

1. The Moshi defaults themselves are still unpinnedDiscoverModel.kt:63,66

This is the one gap I'd still close. The new TV test constructs DiscoverRow directly via the row() helper, so it exercises the consumer of a blank source but never the parse path — and parsing is the entire reason DiscoverModel changed. If someone re-tightens either field (or the generated adapter behaves differently after a Moshi bump), TV Home fails to parse its feed at runtime with a fully green suite.

A small test in modules/services/servers feeding JSON with source/expanded_style omitted through the generated adapter and asserting "" / PlainList would pin it. There's no existing discover-model parse test to extend (src/test/.../servers/ only has sync/), so it'd be a new file — still cheap, since the codegen adapter lives in main and is usable straight from a Moshi.Builder().

2. source = "" still relaxes an invariant the phone side depends on — 18 .source reads across DiscoverAdapter.kt, DiscoverViewModel.kt, CuratedPodcastsCrawler.kt and PodcastGridListFragment.kt

Unchanged from my last pass, and I recognise you may have consciously decided to leave it. Recording it once more rather than pressing: a source-less row used to fail loudly at parse time and now silently becomes "", which the TV loader guards and the phone doesn't. Filtering blank sources in transformWithRegion (DiscoverModel.kt:54-56) would give every consumer the guarantee in one line and make the TV-side check belt-and-braces; a KDoc saying blank means "placeholder row the client fills locally" is the minimum. Either is fine, or neither if you'd rather keep this PR tight — the phone feeds do all send source today.

Note the tolerance is narrower than it looks: the default applies only to an absent key. An explicit "source": null still throws Non-null value 'source' was null. Matches what the feeds actually send, just worth knowing.

Nits (all still open, all optional)

  • TvDiscoverFeedLoader.kt:28 is 122 chars, over AGENTS.md's 120. .editorconfig sets max_line_length=off so spotless won't flag it; a val feed = if (isLoggedIn) { … } else { … } split or hoisting listRepository would fix it.
  • Dropped rows still vanish silently — a Timber.d at loadRow's guard would help next time the feed shape shifts.
  • getLoggedInDiscoverFeed/getLoggedOutDiscoverFeed sit on the shared ListRepository but only exist for platform = tv (/discover/android/content_v3_logged_in.json isn't a thing). A KDoc, or collapsing to getHomeDiscoverFeed(isLoggedIn: Boolean) so the caller doesn't branch, would keep the next reader from wiring them into phone code.

No security or performance concerns. I wasn't able to run :tv:testDebugUnitTest in this environment (gradle invocation isn't in my allowed tools), so the new test is reviewed by reading only — it looks correct against the existing row()/podcastFeed() helpers.

--- · Branch: feat/tv-home-auth-feeds

@sztomek
sztomek force-pushed the feat/tv-search-discover-feed branch from 78bccff to 618a0d8 Compare August 12, 2026 07:36
@sztomek
sztomek force-pushed the feat/tv-home-auth-feeds branch from a9ef5f4 to eba2af7 Compare August 12, 2026 07:40
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 2m 52s —— View job


Code review

  • Read updated diff (origin/feat/tv-search-discover-feed...HEAD)
  • Verify fixes for prior findings
  • Validate the new DiscoverModelTest (source set, adapters, required fields)
  • Post review

Re-reviewed after eba2af7. All three of my earlier findings are now addressed — the last open one, the unpinned Moshi defaults, is closed by the new DiscoverModelTest. Nothing blocking; only optional polish left.

Verified

DiscoverModelTest should pass and pins the right contract. I traced it rather than assuming:

  • modules/services/servers/src/test/kotlin is a live source set — 14 other modules already use src/test/kotlin (analytics, payment, deeplink, sharing, …), so kotlin-android picks it up. Slightly mixed with the module's existing src/test/java/.../sync/, but that's the repo-wide direction, not a problem.
  • The JSON supplies exactly the four properties that would otherwise throw: type, summary_style, title, regions are the only non-null, no-default fields on DiscoverRow. Every remaining omitted key (id, expanded_top_item_label, uuid, category_id, popular, sponsored_ids) is nullable-without-default, which Moshi codegen fills with nullmissingProperty is only generated for non-null no-default properties. So the test isolates the two defaults it's asserting.
  • The hand-built Moshi.Builder() mirrors production's registration for these three adapters (NetworkModule.kt:105-107) and adds no KotlinJsonAdapterFactory, so it exercises the same ksp-generated adapter the app uses. That's the point of the test, and it's set up correctly.
  • ListTypeMoshiAdapter / DisplayStyleMoshiAdapter / ExpandedStyleMoshiAdapter are plain @FromJson/@ToJson classes with no Android dependencies, so this stays a JVM unit test.

Also confirmed unchanged and still correct: getDiscoverFeed() (→ content_v3.json) remains the only feed for phone Discover, DiscoverDeepLinkManager, CuratedPodcastsCrawler, BrowseTreeProvider and onboarding recommendations; the new endpoints have exactly one caller, TvDiscoverFeedLoader.load (TvDiscoverFeedLoader.kt:28). loadCategories' takeIf(String::isNotBlank) guard and the rows without a source are dropped test are both still in place.

Remaining (all optional)

1. The blank-source contract is now documented but still not enforcedDiscoverModel.kt:66

The new comment ("Consumers must skip blank sources") is a real improvement over nothing. Two small ways to get more out of it:

  • Make it KDoc so it surfaces in quick-doc/autocomplete at the ~18 .source read sites on phone (DiscoverAdapter.kt, CuratedPodcastsCrawler.kt:29, PodcastGridListFragment.kt:87), which is where the mistake would actually be made — a // line comment above the property is invisible there:
    /** Blank when the feed omits the key: a placeholder row the client fills locally. Consumers must skip blank sources. */
    @Json(name = "source") override val source: String = "",
  • Or make it an invariant instead of advice, one line in transformWithRegion (DiscoverModel.kt:54-55), so every consumer inherits it and the TV-side check becomes belt-and-braces:
    return this.filter { it.regions.contains(region.code) && it.source.isNotBlank() }
        .map { it.transformWithReplacements(replacements, resources) }

Fine to leave as-is too — phone feeds all send source today, and you've now written the reason down.

2. TvDiscoverFeedLoader.kt:28 is 122 chars, over AGENTS.md's 120. .editorconfig sets max_line_length=off so spotless won't flag it. Collapsing the two repository methods into one getHomeDiscoverFeed(isLoggedIn: Boolean) would fix this and nit 3 at once:

suspend fun load(isLoggedIn: Boolean) = buildRows(listRepository.getHomeDiscoverFeed(isLoggedIn), isLoggedIn)

3. getLoggedInDiscoverFeed/getLoggedOutDiscoverFeed sit on the shared ListRepository with no hint they're TV-only/discover/android/content_v3_logged_in.json doesn't exist, so a phone caller wiring these up gets a 404. A one-line KDoc, or the collapse above.

4. Dropped rows still vanish silently (TvDiscoverFeedLoader.kt:68) — a Timber.d would help the next time the feed shape shifts. Fix this →

No security or performance concerns: both endpoints are unauthenticated static JSON on the same client, fetched once per login-state change.

Caveat: gradle isn't in my allowed tools in this environment, so I couldn't run :modules:services:servers:testDebugUnitTest or :tv:testDebugUnitTest — the new test is validated by tracing the model, adapters, source sets and Moshi codegen rules, not by execution.

--- · Branch: feat/tv-home-auth-feeds

@sztomek
sztomek force-pushed the feat/tv-search-discover-feed branch from 618a0d8 to add79fb Compare August 12, 2026 08:00
@sztomek
sztomek force-pushed the feat/tv-home-auth-feeds branch from eba2af7 to 14ede72 Compare August 12, 2026 08:00
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 1m 53s —— View job


Code review

  • Read updated diff (origin/feat/tv-search-discover-feed...HEAD)
  • Verify prior findings remain addressed
  • Review new changes since last pass
  • Post review

Re-reviewed after the force-push to 14ede72. The tree content is identical to what I reviewed at 07:41 (the branch was rebased; eba2af714ede72), so there's nothing new to assess. All three of my original findings remain addressed. Nothing blocking.

Verified this pass

  • loadCategories blank-URL guard — still in place (TvDiscoverFeedLoader.kt:35-36). The elvis now covers both "no categories row" and "row present but source blank".
  • loadRow blank-source skip (:68) + its rows without a source are dropped test — intact.
  • DiscoverModelTest — re-traced against the current model. The JSON supplies exactly the four non-null, no-default properties on DiscoverRow (type, summary_style, title, regions); every other omitted key is nullable-without-default, so Moshi codegen fills null and no missingProperty fires. The test therefore isolates precisely the two defaults it asserts. One correction to my last pass: I said modules/services/servers had no existing test tree beyond sync/ — that was true of src/test/java, but src/test/kotlin already holds ~10 packages (adapters, di, interceptors, model, podcast, refresh, sync, …), so the source set is unambiguously live and the new file's placement matches the module's own convention. Stronger validation than tracing, same conclusion.
  • Blast radius unchangedgetDiscoverFeed() (→ content_v3.json) is still the only feed for phone Discover (DiscoverViewModel.kt:121), DiscoverDeepLinkManager.kt:24, CuratedPodcastsCrawler.kt:26, BrowseTreeProvider.kt:424 and onboarding recommendations (OnboardingRecommendationsStartPageViewModel.kt:141). The two new endpoints have exactly one caller, TvDiscoverFeedLoader.load.
  • Search pathTvSearchViewModel:45 also goes through buildRows, so it inherits the blank-source skip. Harmless: the search feed sends source on every row today, and dropping a source-less one is the correct behaviour there too.
  • Endpoint/filter consistencyloadRows() still reads syncManager.isLoggedIn() once and uses that same value for both the feed choice and the authenticated filter, so the two can't disagree; the isLoggedInObservable collector reloads on sign-in/out.
  • Line length — the new KDoc line in DiscoverModel.kt:66 lands at exactly 120, within AGENTS.md.

Remaining (all optional, all pre-existing nits)

  1. TvDiscoverFeedLoader.kt:28 is 122 chars, over AGENTS.md's 120. .editorconfig sets max_line_length=off so spotless won't flag it. Collapsing the repository pair into one getHomeDiscoverFeed(isLoggedIn: Boolean) fixes this and nit 2 together:
    suspend fun load(isLoggedIn: Boolean) = buildRows(listRepository.getHomeDiscoverFeed(isLoggedIn), isLoggedIn)
  2. getLoggedInDiscoverFeed/getLoggedOutDiscoverFeed are TV-only but sit on the shared ListRepository with no hint of it — /discover/android/content_v3_logged_in.json doesn't exist, so a phone caller wiring these up gets a 404. A one-line KDoc, or the collapse above.
  3. The source contract comment is a //, not KDoc (DiscoverModel.kt:66) — so it won't surface in quick-doc at the ~18 .source read sites on phone, which is where the mistake would actually be made. Changing // to /** … */ is a one-character-class edit and makes it visible where it matters. I've noted your reasoning for not filtering in transformWithRegion and agree it's the right call for a TV-scoped PR.
  4. Dropped rows still vanish silently (:68) — a Timber.d naming the row id would help next time the feed shape shifts. Fix this →

No security or performance concerns: both endpoints are unauthenticated static JSON on the same client, fetched once per login-state change.

Caveat: gradle isn't in my allowed tools in this environment, so I could not execute :modules:services:servers:testDebugUnitTest or :tv:testDebugUnitTest. The tests are validated by reading the model, adapters, Moshi codegen rules and the module's source-set layout — not by running them.

--- · Branch: feat/tv-home-auth-feeds ·

@sztomek
sztomek force-pushed the feat/tv-search-discover-feed branch from add79fb to a8a6f63 Compare August 12, 2026 08:56
@sztomek
sztomek force-pushed the feat/tv-home-auth-feeds branch from 14ede72 to 425f5e8 Compare August 12, 2026 08:56
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 1m 54s —— View job


Code review

  • Read updated diff (origin/feat/tv-search-discover-feed...HEAD)
  • Diff what changed since the last pass
  • Re-verify prior findings
  • Post review

Re-reviewed after the force-push to 425f5e8. The base branch also moved (a8a6f63), so this synchronize is a rebase — the diff content is byte-identical to what I reviewed at 08:01, and it still applies cleanly on the new base (origin/feat/tv-search-discover-feed is an ancestor of HEAD, no conflict markers, no merge commit). Nothing new to assess and nothing blocking. Already approved by @geekygecko.

Verified on the new base

  • loadCategories blank-URL guard — intact at TvDiscoverFeedLoader.kt:35-36. The elvis covers both "no categories row" and "row present, source blank".
  • loadRow blank-source skip (:68) + the rows without a source are dropped test — intact. Re-checked the test against the current helpers: row(title:, source:, id: = null, …) (TvHomeViewModelTest.kt:616) and podcastFeed(vararg) (:646) both match the call sites, and rowId() = listUuid ?: id ?: title makes the expected listOf("trending") correct.
  • DiscoverModelTest — placement confirmed against the live tree rather than inferred: src/test/kotlin/.../servers/ already holds adapters, di, interceptors, model, podcast, refresh, sync plus two top-level tests, so the source set is unambiguously wired and model/ is the module's own convention. The JSON supplies exactly the four non-null/no-default properties on DiscoverRow (type, summary_style, title, regions); every other omitted key is nullable-without-default, so codegen fills null and no missingProperty fires — the test isolates precisely the two defaults it asserts. The hand-built Moshi.Builder() registers no KotlinJsonAdapterFactory, so lookup resolves the ksp-generated DiscoverRowJsonAdapter, i.e. the same adapter production uses.
  • ExpandedStyle default is behaviour-preserving — re-read ExpandedStyle.kt:14-23: fromString already returns PlainList() for anything unrecognised, so = ExpandedStyle.PlainList() matches the pre-existing fallback exactly. Worth noting the same narrowness as source: ExpandedStyleMoshiAdapter.fromJson takes a non-null String, so an explicit "expanded_style": null still throws — only an absent key gets the default. Matches what the feeds send.
  • Blast radius unchangedgetDiscoverFeed() (→ content_v3.json) is still the sole feed for phone Discover, deep links, CuratedPodcastsCrawler, BrowseTreeProvider and onboarding. The two new endpoints have exactly one caller, TvDiscoverFeedLoader.load.
  • Endpoint/filter consistencyloadRows() reads syncManager.isLoggedIn() once and uses that value for both the feed choice and the authenticated filter, so they can't disagree; the isLoggedInObservable collector reloads on sign-in/out.

Remaining (all optional, all carried over)

  1. TvDiscoverFeedLoader.kt:28 is 122 chars, over AGENTS.md's 120. .editorconfig sets max_line_length=off so spotless won't flag it. Collapsing the repository pair into one method fixes this and nit 2 together:
    suspend fun load(isLoggedIn: Boolean) = buildRows(listRepository.getHomeDiscoverFeed(isLoggedIn), isLoggedIn)
  2. getLoggedInDiscoverFeed/getLoggedOutDiscoverFeed are TV-only but sit on the shared ListRepository with no hint of it — /discover/android/content_v3_logged_in.json doesn't exist, so a phone caller wiring these up gets a 404. A one-line KDoc, or the collapse above.
  3. The source contract comment is a //, not KDoc (DiscoverModel.kt:66) — so it won't surface in quick-doc at the ~18 .source read sites on phone, which is where the mistake would be made. ///** … */ is a trivial edit; the line lands at 120 either way. I've noted and agree with your reasoning for not filtering in transformWithRegion in a TV-scoped PR.
  4. Dropped rows still vanish silently (:68) — a Timber.d naming the row id would help next time the feed shape shifts. Fix this →

No security or performance concerns: both endpoints are unauthenticated static JSON on the same client, fetched once per login-state change.

Caveat: I attempted :modules:services:servers:testDebugUnitTest --tests '*DiscoverModelTest*' this pass and the gradle invocation was denied by my tool permissions, so the tests are still validated by reading the model, adapters, Moshi codegen rules and the module's source-set layout — not by execution. Add Bash(./gradlew*) to --allowedTools if you'd like me to actually run them.
· Branch: feat/tv-home-auth-feeds

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Area] TV [Type] Feature Adding a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants