From d7d6d97779dd243e117c3b45963b755d5a9c4b07 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Thu, 22 Oct 2026 20:45:43 +0530 Subject: [PATCH 1/4] fix(episodes): match up to 1,000 episodes, never replace another show's episode, sync corrected feed addresses --- app/README.md | 1 + .../java/cx/aswin/boxlore/AppContainer.kt | 2 + .../cx/aswin/boxlore/sync/FeedFixSwitches.kt | 20 ++++ .../aswin/boxlore/sync/FeedFixSwitchesTest.kt | 21 ++++ core/catalog/README.md | 4 +- .../boxlore/core/catalog/PodcastRepository.kt | 31 ++++- .../core/catalog/PodcastRepositoryMappers.kt | 18 +++ .../catalog/PodcastRepositoryMemoryPolicy.kt | 6 + .../catalog/PodcastRepositoryCatalogTest.kt | 54 +++++++++ .../PodcastRepositoryEpisodeSupplementTest.kt | 2 + core/database/README.md | 3 +- .../core/database/LocalEpisodeCatalogDao.kt | 7 ++ .../core/database/LocalEpisodeEntity.kt | 3 + .../aswin/boxlore/core/database/PodcastDao.kt | 14 ++- .../core/database/PodcastDaoExtendedTest.kt | 16 +++ core/network/README.md | 2 +- .../aswin/boxlore/core/network/BoxLoreApi.kt | 9 ++ .../core/network/model/PodcastIndexModels.kt | 24 ++++ core/rss/README.md | 5 +- .../core/rss/EpisodeSupplementTipLogic.kt | 26 +++- .../core/rss/LegacyRssSubscriptionUpgrade.kt | 6 + .../core/rss/LocalEpisodeCatalogRefresh.kt | 11 +- .../core/rss/LocalEpisodeCollisionGuard.kt | 105 +++++++++++++++++ .../boxlore/core/rss/StickyEpisodeIdentity.kt | 14 +++ .../core/rss/EpisodeSupplementTipLogicTest.kt | 36 ++++++ .../rss/LocalEpisodeCatalogRepositoryTest.kt | 85 ++++++++++++++ .../rss/LocalEpisodeCollisionGuardTest.kt | 111 ++++++++++++++++++ feature/info/README.md | 2 +- .../info/PodcastInfoSupplementSupport.kt | 12 +- 29 files changed, 625 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt create mode 100644 app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt create mode 100644 core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt create mode 100644 core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuardTest.kt diff --git a/app/README.md b/app/README.md index 6bb9b62d3..111188ccf 100644 --- a/app/README.md +++ b/app/README.md @@ -66,6 +66,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - `DownloadServiceLauncherHolder` is installed with `MediaDownloadService::class.java` so `:core:downloads` can launch the foreground download service without depending on `:core:playback`. - `LegacyWorkerFactory` maps legacy worker class names to current worker implementations for WorkManager continuity. - `MainActivity` hosts theme, edge-to-edge setup, app update hooks, surveys, library import state (full-screen `OpmlImportDialog` overlay so onboarding welcome never shows through), the selected floating or classic navigation presentation, and the player overlay. Its start/stop lifecycle informs the application-scoped playback repository whether UI position polling is needed; background audio, persistence, notifications, Cast, and MediaSession behavior remain service-owned. +- **Feed fix off switches** (`sync/FeedFixSwitches`): PostHog kill switches for the feed and episode fixes, passed into core modules as lambdas. A fix runs unless its flag is on; no answer (PostHog not ready, flag missing or inactive, an error) keeps it on. `feed-fix-match-baseline-off` sends baselines back to the 100-item page. `FeedFixSwitchesTest` covers the default. - NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 6e8ce9ee4..d65dba3a9 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -48,6 +48,7 @@ import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository import cx.aswin.boxlore.core.rss.RssPodcastRepository import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker import cx.aswin.boxlore.sync.CloudSyncTriggerCoordinator +import cx.aswin.boxlore.sync.FeedFixSwitches import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -186,6 +187,7 @@ class AppContainer( rssRepository = rssPodcastRepository, episodeSupplementRepository = episodeSupplementRepository, localEpisodeCatalog = subscribedEpisodeCatalog, + useEpisodeMatchBaseline = { FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) }, ) } diff --git a/app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt b/app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt new file mode 100644 index 000000000..61bbfef8a --- /dev/null +++ b/app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt @@ -0,0 +1,20 @@ +package cx.aswin.boxlore.sync + +import com.posthog.PostHog +import cx.aswin.boxlore.core.analytics.PostHogStartup + +/** + * PostHog off switches for the feed and episode fixes, so one can be turned off without an app + * update. Each fix runs unless its flag is on: with no answer (PostHog not ready, the flag missing + * or inactive, an error) the fix stays on. To turn one off, enable its flag for everyone. + */ +internal object FeedFixSwitches { + /** Baselines read up to 1,000 episodes from `/episodes/match` instead of the 100-item page. */ + const val MATCH_BASELINE_OFF = "feed-fix-match-baseline-off" + + /** True unless [offFlag] is on. [isFlagOn] is the PostHog read, replaced in tests. */ + fun isOn(offFlag: String, isFlagOn: (String) -> Boolean = ::postHogFlagOn,): Boolean = + !runCatching { isFlagOn(offFlag) }.getOrDefault(false) + + private fun postHogFlagOn(flag: String): Boolean = PostHogStartup.isReady && PostHog.isFeatureEnabled(flag, false) +} diff --git a/app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt b/app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt new file mode 100644 index 000000000..2cd324047 --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt @@ -0,0 +1,21 @@ +package cx.aswin.boxlore.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FeedFixSwitchesTest { + @Test + fun aFixRunsUnlessItsOffFlagIsOn() { + val asked = mutableListOf() + assertTrue(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { asked += it; false }) + assertFalse(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { true }) + assertEquals(listOf("feed-fix-match-baseline-off"), asked) + } + + @Test + fun noAnswerFromPostHogKeepsTheFixOn() { + assertTrue(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { error("PostHog not started") }) + } +} diff --git a/core/catalog/README.md b/core/catalog/README.md index bdee80573..89d67abb7 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -14,7 +14,7 @@ Failed consent writes keep activation off, and local notification flags prevent - RSS notification activation requires explicit public-feed disclosure acceptance through `setNotificationsEnabled(..., acceptRssDisclosure = true, disclosedFeedUrl = savedUrl)`. `DeviceRssNotificationConsent` stores only URL fingerprints in `noBackupFilesDir`; changed URLs, restore and remote cloud settings cannot supply acceptance. Canonical `rss:` IDs require disclosure even with stale source metadata. Refused or revoked activation also removes this device's old registration; a library cleared during acceptance cannot publish a deleted show. Notification settings remain independent of auto-download. The shared checker receives only explicitly accepted public feeds. `TrackedPodcastRtdbLogic.topic` maps `rss:` to FCM-safe `rss_` while retaining canonical IDs in payloads. JSON/cloud RSS restore retains the original saved show ID across redirects. - RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. -- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` remains for **first** catalog persist rematch only. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). +- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` is the strict PI baseline for feed-episode matching (first catalog persist, library restore, Info, push hydration): up to 1,000 newest episodes as match fields only from `GET episodes/match` (`MAX_MATCH_BASELINE`), so a show with more than 100 episodes keeps its newer episodes on the IDs history, progress and downloads use. It falls back to the old oldest-100 page when the route fails or the `useEpisodeMatchBaseline` off switch (a PostHog kill switch passed by the app) is off. Full pages keep the 100 cap. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). - `syncSubscriptions` (`POST /sync`) is PI-only for shows that are **not** catalog-ready and **not** still on extras opt-in. The PI ids go out in chunks of 20 (`PI_SYNC_CHUNK_SIZE`), at most two calls at a time (`PI_SYNC_PARALLELISM`), because the API serves only the first 40 ids of a call: whole-library callers (Smart Downloads, backup restore) used to lose every show past the 40th. A failed chunk drops only its own shows' tips. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). - Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead` / Meili) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: one CF embed → Qdrant podcasts + episodes; additive `feeds` + legacy `items`). - `logic.mergeShowSearchResults` dedupes Meili vs hybrid by id / itunes / feed URL. Missing/generic typeahead genres are filled from matching hybrid results when their known genre agrees; title-only matches and conflicting genres are not enrichment evidence. Existing catalog identity, other metadata, order and grouping stay unchanged. @@ -131,7 +131,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - `PodcastRepositoryPlayerPrefsTest` checks the player preferences (and their legacy-file migration) open on first use, not while the repository is constructed on the main thread at startup. - Unit tests live under `core/catalog/src/test`. - `PodcastRepositoryRequestBoundsTest` (MockWebServer that, like the API, serves only 40 ids per `/sync` call): a 60-show library syncs every show in three chunks of at most 20 with at most two in flight, a failed chunk drops only its own shows, and legacy, v2 and bootstrap recommendation requests send at most 12 interests. -- Existing coverage includes `PodcastRepositoryCatalogTest`, `PodcastRepositoryEpisodeSupplementTest` (offset-0 merge, later-page PI-only, `mergeSupplements = false` PI baseline, search union, opted-in `/sync` skip), `LegacyRssRepairLogicTest` (URL/GUID-only acceptance, conflicts, transient failures), `LatestEpisodeTipLogicTest`, `TrackedPodcastRtdbLogicTest` (HTTPS `feedUrl` on notification RTDB rows), `ShowSearchMergeTest`, `DirectFeedSyncOrderTest`, `LibraryBackupDirectFeedLogicTest` / `LibraryBackupDirectFeedRestoreTest` (JSON v6 opt-in export targeting, Gson-null skip, restore-before-`/sync` plan, RTDB patch before feed refresh), `LibraryBackupManualOrderPinsTest` (Manual sort + id lists round-trip; older JSON without lists), `LibraryBackupImportLogicTest` (restore/OPML rethrow cancellation), `OpmlImportLogicTest` (catalog URL/title/feed match before `rss:` fallback, URL scheme/slash/`www` candidates, HTTPS rewrite for feed peek), `SubscriptionForegroundSyncLogicTest` (cooldown / subscriptions-first fetch / catalog ingest id gates), `SubscriptionRepositoryTest` (subscription tombstoning with `unsubscribedAt`, resubscription tombstone clearing, and dirty tracking on settings updates), `InstallReferrerManager` channel derivation / attribution callback seams, `SubscriptionForegroundSync` delay / `requestRefresh` / periodic / `SubscriptionForegroundSyncIngestTest` (`requestCatalogIngest` cooldown bypass, RSS skip, finished emit, unsubscribe TTL, expiry sweep isolation) / direct-feed concurrency and failure isolation (including missing-feed URL recovery), content orchestration tests, content signal enrichment, grouped sections, recent section intent storage, cross-promotion detection, transcript behavior, and dependency-holder behavior. +- Existing coverage includes `PodcastRepositoryCatalogTest` (including the match baseline: 1,000-item request, skipped rows, fallback to the page path, off switch), `PodcastRepositoryEpisodeSupplementTest` (offset-0 merge, later-page PI-only, `mergeSupplements = false` PI baseline, search union, opted-in `/sync` skip), `LegacyRssRepairLogicTest` (URL/GUID-only acceptance, conflicts, transient failures), `LatestEpisodeTipLogicTest`, `TrackedPodcastRtdbLogicTest` (HTTPS `feedUrl` on notification RTDB rows), `ShowSearchMergeTest`, `DirectFeedSyncOrderTest`, `LibraryBackupDirectFeedLogicTest` / `LibraryBackupDirectFeedRestoreTest` (JSON v6 opt-in export targeting, Gson-null skip, restore-before-`/sync` plan, RTDB patch before feed refresh), `LibraryBackupManualOrderPinsTest` (Manual sort + id lists round-trip; older JSON without lists), `LibraryBackupImportLogicTest` (restore/OPML rethrow cancellation), `OpmlImportLogicTest` (catalog URL/title/feed match before `rss:` fallback, URL scheme/slash/`www` candidates, HTTPS rewrite for feed peek), `SubscriptionForegroundSyncLogicTest` (cooldown / subscriptions-first fetch / catalog ingest id gates), `SubscriptionRepositoryTest` (subscription tombstoning with `unsubscribedAt`, resubscription tombstone clearing, and dirty tracking on settings updates), `InstallReferrerManager` channel derivation / attribution callback seams, `SubscriptionForegroundSync` delay / `requestRefresh` / periodic / `SubscriptionForegroundSyncIngestTest` (`requestCatalogIngest` cooldown bypass, RSS skip, finished emit, unsubscribe TTL, expiry sweep isolation) / direct-feed concurrency and failure isolation (including missing-feed URL recovery), content orchestration tests, content signal enrichment, grouped sections, recent section intent storage, cross-promotion detection, transcript behavior, and dependency-holder behavior. - `RssNotificationReconcilerTest` runs `FirebaseRssNotificationRemote` against a fake that applies the live `tracked_podcasts` write rule: cleanup keeps the node and drops only `feedUrl`, a never-published key still leaves its topic, and any other failure keeps the topic and the journal entry. - RSS ID and matcher tests live in `:core:rss`; smart-queue tests live in `:core:playback`. - `LibraryBackupExportTest` pins the JSON export sections behind the Settings summary (no `directFeedOptIns`) and the lowercase OPML title. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt index 995da0ee0..2e1271faf 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt @@ -41,6 +41,11 @@ class PodcastRepository( * [NetworkModule.createBoxLoreApi] builds the cached OkHttp client from [context]. */ boxLoreApi: BoxLoreApi? = null, + /** + * Off switch for the 1,000-item match baseline ([loadPiEpisodesForBaseline]). The app passes + * a PostHog kill switch; false means the old 100-item page path. + */ + private val useEpisodeMatchBaseline: () -> Boolean = { true }, ) : cx.aswin.boxlore.core.domain.ports.PodcastCatalogPort { val api: BoxLoreApi = boxLoreApi ?: NetworkModule.createBoxLoreApi(baseUrl, context) internal val contentCatalogPreferences = @@ -278,12 +283,22 @@ class PodcastRepository( } /** - * Strict PI page for feed-extra matching. Throws on HTTP/network failure so + * Strict PI baseline for feed-episode matching (sync, restore, Info): the episodes whose IDs + * history, progress and downloads already use. Throws on HTTP/network failure so * [EpisodeSupplementPort.refreshFromFeed] does not persist extras against an * empty baseline (which would treat every feed item as feed-only). + * + * Reads up to [limit] (at most 1,000) newest episodes from `/episodes/match`, match fields only. + * The full-page path is capped at 100 for memory and returns the oldest 100, so a show with more + * episodes left its newer ones unmatched: they got negative IDs, detached from history and + * downloads, and showed twice on Info. Falls back to that page when the route fails or the + * switch is off. */ suspend fun loadPiEpisodesForBaseline(feedId: String, limit: Int,): List = withContext(Dispatchers.IO) { val resolvedId = resolvePodcastIndexFeedId(feedId) + if (useEpisodeMatchBaseline()) { + loadMatchBaseline(resolvedId, limit)?.let { return@withContext it } + } val safeLimit = clampPageLimit(limit) val response = api.getEpisodesPaginated(publicKey, resolvedId, safeLimit, 0, "oldest").execute() check(response.isSuccessful && response.body() != null) { @@ -292,6 +307,20 @@ class PodcastRepository( response.body()!!.items.mapNotNull { mapToEpisode(it) } } + /** `/episodes/match` as baseline episodes, or null to use the page path (HTTP or network error). */ + private fun loadMatchBaseline(resolvedId: String, limit: Int): List? = try { + val response = api.getEpisodeMatchList(publicKey, resolvedId, limit.coerceIn(1, MAX_MATCH_BASELINE)).execute() + val body = response.body() + if (response.isSuccessful && body != null) body.items.mapNotNull { it.toBaselineEpisode(resolvedId) } else null + } catch (e: java.io.IOException) { + android.util.Log.w("PodcastRepository", "match baseline failed for $resolvedId; using the page path", e) + null + } catch (e: IllegalArgumentException) { + // An unreadable body (the JSON converter's SerializationException is one). + android.util.Log.w("PodcastRepository", "match baseline unreadable for $resolvedId; using the page path", e) + null + } + private suspend fun getRssEpisodesPaginated(feedId: String, limit: Int, offset: Int, sort: String,): EpisodePage = try { val episodes = rssRepository.getEpisodes(feedId, limit, offset, sort) val total = rssRepository.episodeCount(feedId) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMappers.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMappers.kt index 7f10c709d..e7ac99a1b 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMappers.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMappers.kt @@ -62,6 +62,24 @@ internal fun mapFeedsToPodcasts(feeds: List 0L } ?: return null + val audioUrl = enclosureUrl ?: return null + return Episode( + id = episodeId.toString(), + title = title.orEmpty(), + description = "", + audioUrl = audioUrl, + podcastId = podcastId, + publishedDate = datePublished ?: 0L, + ) +} + internal fun mapToEpisode(item: cx.aswin.boxlore.core.network.model.EpisodeItem): Episode? { val audioUrl = item.enclosureUrl ?: return null android.util.Log.d( diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMemoryPolicy.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMemoryPolicy.kt index cf7a6ad45..382c8668a 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMemoryPolicy.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryMemoryPolicy.kt @@ -1,6 +1,12 @@ package cx.aswin.boxlore.core.catalog internal const val MAX_SAFE_PAGE_LIMIT = 100 + +/** + * Most items a match baseline asks for (`/episodes/match`). Safe above [MAX_SAFE_PAGE_LIMIT]: match + * items carry no description or full text (about 200 KB for 1,000). + */ +internal const val MAX_MATCH_BASELINE = 1000 internal const val DEFAULT_EPISODE_CACHE_TTL_MS = 300_000L // 5 minutes internal const val DEFAULT_RECOMMENDATIONS_CACHE_TTL_MS = 900_000L // 15 minutes diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt index 623964af0..e5d04e17b 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt @@ -282,6 +282,60 @@ class PodcastRepositoryCatalogTest { ) } + @Test + fun `match baseline reads up to 1000 newest episodes from episodes-match`() = runTest(testDispatcher) { + // The page path is capped at the oldest 100, so newer episodes of a big show went unmatched. + val items = (1..150).joinToString(",") { i -> + """{"id":$i,"guid":"g$i","title":"Ep $i","enclosureUrl":"https://cdn.example/$i.mp3","datePublished":${1_700_000_000 + i}}""" + } + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"items":[$items,{"id":0,"enclosureUrl":"https://x/0.mp3"},{"id":7,"title":"no audio"}]}""")) + + val baseline = repository.loadPiEpisodesForBaseline("90001", limit = 5000) + + assertEquals(150, baseline.size, "items without an ID or audio are skipped") + assertEquals(Episode(id = "150", title = "Ep 150", description = "", audioUrl = "https://cdn.example/150.mp3", podcastId = "90001", publishedDate = 1_700_000_150), baseline.last()) + val recorded = server.takeRequest() + assertEquals("/episodes/match?id=90001&max=1000", recorded.path) + assertEquals(APP_KEY, recorded.getHeader("X-App-Key")) + assertEquals(1, server.requestCount) + } + + @Test + fun `match baseline falls back to the page path when the route fails`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(404)) + server.enqueue( + MockResponse().setHeader("Content-Type", "application/json") + .setBody("""{"items":[{"id":11,"title":"Old","enclosureUrl":"https://cdn.example/11.mp3","datePublished":1}],"hasMore":false}"""), + ) + + val baseline = repository.loadPiEpisodesForBaseline("90001", limit = 1000) + + assertEquals(listOf("11"), baseline.map { it.id }) + assertTrue(server.takeRequest().path!!.startsWith("/episodes/match")) + val page = server.takeRequest().path!! + assertTrue(page.startsWith("/episodes?") && page.contains("limit=100") && page.contains("sort=oldest"), page) + } + + @Test + fun `match baseline off switch keeps the page path`() = runTest(testDispatcher) { + val off = + PodcastRepository( + baseUrl = server.url("/").toString(), + publicKey = APP_KEY, + context = fakeContext(), + rssRepository = RssPodcastRepository.createForTests(context = fakeContext(), database = fakeDatabase()), + ioDispatcher = testDispatcher, + boxLoreApi = NetworkModule.createBoxLoreApi(server.url("/").toString(), OkHttpClient()), + useEpisodeMatchBaseline = { false }, + ) + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"items":[],"hasMore":false}""")) + + off.loadPiEpisodesForBaseline("90001", limit = 1000) + + assertTrue(server.takeRequest().path!!.startsWith("/episodes?")) + assertEquals(1, server.requestCount) + } + private fun fixtureBody(resourcePath: String): MockResponse { val json = requireNotNull(javaClass.classLoader?.getResource(resourcePath)) { diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt index 4ca7ab5e1..27e98e04b 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt @@ -231,6 +231,8 @@ class PodcastRepositoryEpisodeSupplementTest { @Test fun `loadPiEpisodesForBaseline throws on HTTP failure`() = runTest(testDispatcher) { + // Both sources fail: /episodes/match, then the page path it falls back to. + server.enqueue(MockResponse().setResponseCode(500)) server.enqueue(MockResponse().setResponseCode(500)) val thrown = runCatching { repository.loadPiEpisodesForBaseline("900041", limit = 1000) }.exceptionOrNull() diff --git a/core/database/README.md b/core/database/README.md index a7282082e..172ba2da6 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -12,7 +12,7 @@ Owns the main Room database, entities, DAOs, type converters, and migrations for - `PodcastScoring` provides interpretable listening, like, freshness, preference, and optional subscription-recency signals. Canonical Your Shows ranking disables its legacy subscription-recency term so `:core:ranking` can apply one bounded recency policy after normalization; other callers retain the existing `600 / (1 + hours/24)` default. - `BoxLoreDatabase` and its `getDatabase` factory. - Entities: `PodcastEntity`, `ListeningHistoryEntity`, `ListeningSessionEntity`, `ListeningRollupEntity`, `DownloadedEpisodeEntity`, `RssEpisodeEntity`, `EpisodeSupplementEntity`, `EpisodeSupplementItemEntity`, `LocalEpisodeFeedEntity`, `LocalEpisodeEntity`, `FolderEntity`, `PodcastFolderCrossRef`, `entities.QueueItem`, and `entities.QueueMetadataEntity`. -- DAOs: `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL after library restore. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`). `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. +- DAOs: `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL found on the device (address recovery, auto-download, library restore) and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`); `findOwners` reports which (show, key) holds each episode ID so writers check before `upsertEpisodes` replaces a row. `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. - `EpisodeSupplementDao.replaceAll` / `upsertSupplementAndOptionalItems` keep validator rows and tip items in one Room transaction. - `ListeningInsightsMaintenance` for rolling session/rollup maintenance (via `BoxLoreDatabase.listeningInsightsMaintenance()`). - `ListeningRollupMerge` for pure session→rollup merge logic. @@ -79,6 +79,7 @@ src/main/java/cx/aswin/boxlore/core/database/ - Unit tests live under `core/database/src/test`. - `BriefingArtworkCompatibilityTest` covers upgrade/reopen, restored rows, all region aliases, recursive-trigger safety, unchanged non-Brief artwork and preservation of playback/download/queue state. Opening recreates trigger definitions transactionally, including after a database comes from another application variant, so future restores use the current authority and aliases. +- `PodcastDaoExtendedTest.setFeedUrlMarksACorrectedAddressForTheNextPush` covers a corrected address marking the row dirty and an unchanged one staying clean. - `PodcastDaoInMemoryTest` verifies the in-memory Room DAO path and RSS notification cleanup selection, including disabled/unsubscribed rows with stale source metadata. - `ListeningRollupMergeTest` covers session→rollup merge, including the empty-sessions guard. `ListeningHistoryDaoInMemoryTest` covers insert-if-absent conflict safety and playback completion updates preserving listener-owned fields such as likes. `ListeningHistoryDao.repairPlaybackArtworkLinks` and `QueueDao.repairPlaybackArtworkLinks` swap player-only `.collage` artwork links saved by older builds for the episode's cover (local, then RSS catalogue), else the show's, else the row's own show cover, else null. A history row is marked dirty only when every dead link found a real cover, so an emptied field never overwrites a cover another device uploaded; `QueueDao.repairPlaybackArtworkLinksForSync` marks a changed queue dirty in the same transaction without changing its sequence, so the cloud copy drops the dead links, and its order is untouched. `ListeningHistoryDaoInMemoryTest` and `QueueDaoInMemoryTest` cover each source, the row's own show cover, emptied rows staying clean, the queue marked dirty only when a row changed, untouched rows and a second run changing nothing. - `Migration36To37Test` verifies Room schema upgrade from version 36 to 37, adding `unsubscribedAt`, `isDirty`, `syncedAt` to `podcasts`, `likedAt` and indices to `listening_history`, creating `queue_metadata`, and preserving existing row data. diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt index 981357948..d493c9a64 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt @@ -47,6 +47,13 @@ interface LocalEpisodeCatalogDao { ) suspend fun listIdentities(podcastId: String): List + /** + * Owners of these episode IDs across every show, so a write can check before [upsertEpisodes] + * replaces a row (docs/EPISODE_IDS.md §4). Callers pass at most 500 IDs per call. + */ + @Query("SELECT episodeId, podcastId, guid FROM local_episodes WHERE episodeId IN (:episodeIds)") + suspend fun findOwners(episodeIds: List): List + @Query( """ SELECT * FROM local_episodes diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt index c7c72ed61..d92565370 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt @@ -74,3 +74,6 @@ data class LocalEpisodeEntity( /** Lightweight identity map for sticky upserts (no payload). */ data class LocalEpisodeIdentity(val episodeId: String, val guid: String, val audioUrl: String,) + +/** Which (show, catalog key) holds a local episode ID; [guid] stores the catalog key. */ +data class LocalEpisodeOwner(val episodeId: String, val podcastId: String, val guid: String,) diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt index 32c6e0cf4..95c901b6c 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt @@ -129,8 +129,18 @@ interface PodcastDao { @Query("UPDATE podcasts SET rssHasNewEpisodes = 1 WHERE podcastId = :id") suspend fun markHasNewEpisodes(id: String) - /** Publisher HTTPS URL for PI shows opted into Missing episodes? (library restore). */ - @Query("UPDATE podcasts SET feedUrl = :feedUrl WHERE podcastId = :id") + /** + * Saves a publisher HTTPS feed URL found on this device (address recovery, auto-download, + * library restore) and marks the row dirty so the next sync push sends it, like the other + * single-field edits. Before, the corrected address stayed on this device only. An unchanged + * address writes nothing, so repeated recovery passes don't re-push. + */ + @Query( + """ + UPDATE podcasts SET feedUrl = :feedUrl, isDirty = 1 + WHERE podcastId = :id AND (feedUrl IS NULL OR feedUrl != :feedUrl) + """, + ) suspend fun setFeedUrl(id: String, feedUrl: String,) @Query("UPDATE podcasts SET customGenre = :customGenre, customGenreIcon = :customGenreIcon, isDirty = 1 WHERE podcastId = :id") diff --git a/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastDaoExtendedTest.kt b/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastDaoExtendedTest.kt index d3c9c4f66..1125b6dae 100644 --- a/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastDaoExtendedTest.kt +++ b/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastDaoExtendedTest.kt @@ -209,6 +209,22 @@ class PodcastDaoExtendedTest { assertEquals("https://feeds.example/show.xml", dao.getPodcast("1258562")!!.feedUrl) } + @Test + fun setFeedUrlMarksACorrectedAddressForTheNextPush() = runTest { + // A corrected address used to stay on the device: the row was never marked dirty. + dao.upsert(podcast("1258562").copy(feedUrl = "https://old.example/feed.xml", isDirty = false, syncedAt = 500L)) + assertTrue(dao.getDirtyPodcasts().isEmpty()) + + dao.setFeedUrl("1258562", "https://new.example/feed.xml") + val dirty = dao.getDirtyPodcasts().single() + assertEquals("https://new.example/feed.xml", dirty.feedUrl) + + dao.markPodcastsSynced(listOf("1258562"), timestamp = 900L) + dao.setFeedUrl("1258562", "https://new.example/feed.xml") + assertTrue("an unchanged address is not pushed again", dao.getDirtyPodcasts().isEmpty()) + assertEquals(900L, dao.getPodcast("1258562")!!.syncedAt) + } + @Test fun updateCustomGenreUpdatesAndClearsOverrides() = runTest { dao.upsert(podcast("1")) diff --git a/core/network/README.md b/core/network/README.md index 8dc1e501c..28bbe66ad 100644 --- a/core/network/README.md +++ b/core/network/README.md @@ -6,7 +6,7 @@ Owns the Retrofit API boundary, OkHttp/Retrofit construction, request and respon ## Public API -- `BoxLoreApi` defines the Retrofit service surface, including additive `GET search/typeahead` (Meili show typeahead), cloud sync endpoints (`POST user/sync/push`, `POST user/sync/pull`, `DELETE user/sync/account`), and legacy routes. Legacy `GET search` and `POST sync` are unchanged. +- `BoxLoreApi` defines the Retrofit service surface, including additive `GET search/typeahead` (Meili show typeahead), cloud sync endpoints (`POST user/sync/push`, `POST user/sync/pull`, `DELETE user/sync/account`), `GET episodes/match` (`EpisodeMatchResponse`: up to 1,000 newest episodes with only `id`, `guid`, `title`, `enclosureUrl`, `datePublished`, for baselines), and legacy routes. Legacy `GET search` and `POST sync` are unchanged. - `FirebaseAuthAuthenticator` provides an OkHttp `Authenticator` handling HTTP 401 Unauthorized responses with Kotlin `Mutex` refresh serialization to prevent token refresh stampedes (consuming `AuthRepository` from `:core:auth`). - `NetworkModule` creates OkHttp, Retrofit, and related network clients, with automatic header redaction for `Authorization`, `X-App-Key`, `X-Firebase-AppCheck`, `X-Boxlore-Session`, `X-Session-Renew` and `X-Session-Proof`. Every request carries `X-App-Version` and `X-App-Channel` (`play` or `github`) when the app sets `appVersion` / `appChannel`. `createSessionExchangeClient()` is the plain client for `POST /auth/session` (no session interceptor, no Firebase authenticator, no cache). - **API sessions (`session/`, 2026-10-17).** This build authenticates with API session tickets only and **never sends the bundled app key**: `ApiSessionInterceptor` strips `X-App-Key` from every request. `NetworkModule.sessionManager` is set by the app at startup. diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/BoxLoreApi.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/BoxLoreApi.kt index ddbc18f1e..577abf0ba 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/BoxLoreApi.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/BoxLoreApi.kt @@ -7,6 +7,7 @@ import cx.aswin.boxlore.core.network.model.BootstrapRequest import cx.aswin.boxlore.core.network.model.BootstrapResponse import cx.aswin.boxlore.core.network.model.ContentCatalogResponse import cx.aswin.boxlore.core.network.model.CuratedCuriosityResponseDto +import cx.aswin.boxlore.core.network.model.EpisodeMatchResponse import cx.aswin.boxlore.core.network.model.EpisodesPaginatedResponse import cx.aswin.boxlore.core.network.model.EpisodesResponse import cx.aswin.boxlore.core.network.model.FeedbackRequest @@ -104,6 +105,14 @@ interface BoxLoreApi { @Query("sort") sort: String = "newest" ): retrofit2.Call + /** Up to 1,000 newest episodes as match fields only, for baselines (see [EpisodeMatchResponse]). */ + @GET("episodes/match") + fun getEpisodeMatchList( + @Header("X-App-Key") publicKey: String, + @Query("id") feedId: String, + @Query("max") max: Int = 1000, + ): retrofit2.Call + @GET("episodes/search") fun searchEpisodes( @Header("X-App-Key") publicKey: String, diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/PodcastIndexModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/PodcastIndexModels.kt index e3f44a2b7..9e7afbff6 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/PodcastIndexModels.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/PodcastIndexModels.kt @@ -130,6 +130,30 @@ data class EpisodesPaginatedResponse( val limit: Int = 20 ) +/** + * `GET /episodes/match`: a show's newest episodes (up to 1,000) with only the fields a baseline + * match needs. Full pages keep [EpisodesPaginatedResponse], which the app caps at 100 items. + */ +@Serializable +data class EpisodeMatchResponse( + @SerialName("items") + val items: List = emptyList(), +) + +@Serializable +data class EpisodeMatchItem( + @SerialName("id") + val id: Long? = null, + @SerialName("guid") + val guid: String? = null, + @SerialName("title") + val title: String? = null, + @SerialName("enclosureUrl") + val enclosureUrl: String? = null, + @SerialName("datePublished") + val datePublished: Long? = null, +) + @Serializable data class SingleEpisodeResponse( @SerialName("status") diff --git a/core/rss/README.md b/core/rss/README.md index 07d9d48c9..98c0a2b58 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -20,7 +20,7 @@ Failed automatic fetches record a six-hour retry boundary while keeping catalogs - `EpisodeRecipeId` computes boxlore episode IDs for numeric shows (show IDs `1..2^53 - 1`, the same range the JS copy accepts): a SHA-256 handle over (show ID, catalog key) in `[2^52, 2^53 - 1]`, so it never overlaps Podcast Index or negative RSS IDs. It is a pure function and isn't used for storage yet. `catalogKey` trims an explicit character set equal to Kotlin's `trim()` on Android (which, unlike the desktop JVM, also trims U+180E); `StickyEpisodeIdentity.catalogKey` uses the same set, so stored keys don't change. Every implementation must match `src/test/resources/fixtures/episode-recipe-vectors.json`; the scripts copy reads the same file. The full identity rules are in [`docs/EPISODE_IDS.md`](../../docs/EPISODE_IDS.md). - `RssSourceMatcher` provides migration and matching heuristics between Podcast Index and RSS sources. - `EpisodeSupplementRepository` implements `EpisodeSupplementPort`: fetches a PI show’s public feed and caches **feed-only** episodes under the Podcast Index id. This is **not** a subscription — it never creates `rss:` library rows, never migrates/retires a PI subscription, and never writes FCM topics or autodownload flags. Prefer `RssPodcastRepository` / `RssSubscriptionPort` for true RSS library ownership. Catalog subscription code may read `hasDirectFeedOptIn` when attaching `feedUrl` to notification RTDB rows. `listDirectFeedOptIns` exports HTTPS publisher URLs for library JSON; `restoreDirectFeedOptIn` writes a stub row (no ETag) so import can keep Missing episodes? on even before the feed GET, and does not replace existing extras. -- After a successful feed refresh, `EpisodeSupplementOutcome.Success.newestFeedEpisode` is the tip callers should write to Room `podcasts.latestEpisode` (Home filter chips). `refreshFromFeed` / `optInFromFeedIfDisconnected` fall back to the stored supplement HTTPS `feedUrl` when the request URL is missing or not HTTPS. `refreshFromFeed(RefreshFromFeedRequest)` can load the PI baseline in parallel with the publisher GET; a throwing `loadBaseline` is a feed-load failure and does **not** persist (empty PI must not mark every RSS item as feed-only). `isPublisherFeedUnchanged` HEADs stored ETag / Last-Modified so launch sync can skip GET+PI when the feed did not change. `SubscriptionForegroundSync` and FCM hydration (`NewEpisodePushHydration`) both use `refreshFromFeed` so Home chips and Library get the full extra list; `resolveNewestTipFromFeed` remains the payload guid/enclosure match fallback. Feed load failures return a fixed user-facing message (never raw HTTP/parser text). +- After a successful feed refresh, `EpisodeSupplementOutcome.Success.newestFeedEpisode` is the tip callers should write to Room `podcasts.latestEpisode` (Home filter chips). A tip that matches a baseline episode keeps its ID and takes every empty field (description, artwork, length, Podcast 2.0 links) from the feed episode (`Episode.withBlanksFrom`), because a match baseline carries only the ID, title, audio and date; a full Podcast Index row is unchanged. `refreshFromFeed` / `optInFromFeedIfDisconnected` fall back to the stored supplement HTTPS `feedUrl` when the request URL is missing or not HTTPS. `refreshFromFeed(RefreshFromFeedRequest)` can load the PI baseline in parallel with the publisher GET; a throwing `loadBaseline` is a feed-load failure and does **not** persist (empty PI must not mark every RSS item as feed-only). `isPublisherFeedUnchanged` HEADs stored ETag / Last-Modified so launch sync can skip GET+PI when the feed did not change. `SubscriptionForegroundSync` and FCM hydration (`NewEpisodePushHydration`) both use `refreshFromFeed` so Home chips and Library get the full extra list; `resolveNewestTipFromFeed` remains the payload guid/enclosure match fallback. Feed load failures return a fixed user-facing message (never raw HTTP/parser text). - `optInFromFeedIfDisconnected` fetches the feed and persists a supplement **only** when there are feed-only episodes or a newer tip vs the PI baseline; otherwise returns `NoDisconnect` (no opt-in row). - `EpisodeSupplementListMerge` dedupes PI pages with feed-only supplement episodes using the same identity rules as `EpisodeSupplementMatcher` (audio URL, then non-empty title with a date window). **Page/list merge** (Home, Podcast Info episode list, Smart Queue) runs only in `PodcastRepository`. **In-show search** is a separate union: catalog prefers the network/PI hits; Podcast Info prefers supplement hits so feed-only extras keep their enriched titles. - `EpisodeSupplementDisconnectLogic` decides auto opt-in on subscribe. @@ -79,12 +79,13 @@ src/main/java/cx/aswin/boxlore/core/rss/ - RSS episode IDs are strings encoding deterministic negative `Long` values to avoid collision with positive Podcast Index IDs. - RSS episode and podcast rows are persisted in the main database owned by `:core:database`. - Exact legacy upgrades retain the old RSS row and episodes as an unsubscribed compatibility alias; the active PI row owns an equivalent ready local catalog using the same negative episode ids. +- Local catalog writes never replace another episode (`LocalEpisodeCollisionGuard`, [docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md) §4). Two shows that read the same feed (one feed listed twice in Podcast Index) mint the same IDs from one feed namespace; inside the refresh transaction, a row whose ID another (show, catalog key) holds, or that an earlier row of the batch took, gets a reserved negative ID (`StickyEpisodeIdentity.reservedEpisodeId`, stable across refreshes), while the same (show, key) keeps updating its row. A row with no free ID after 8 tries is left out of that write. The legacy RSS upgrade keeps its IDs for listener state, so it stays on RSS instead when another show holds one. - Package root is `cx.aswin.boxlore.core.rss`. ## Testing notes - Unit tests live under `core/rss/src/test`. -- Existing coverage includes deterministic ID contracts, source matching heuristics, exact legacy URL/GUID upgrade gates, feed-client helpers, episode-supplement matching (including empty titles and distant same-title dates), list merge, tip-resolution (PI id vs negative feed id, blank metadata), supplement artwork fallback (item / channel / show), repository require/failure mapping, disconnect / auto-opt-in predicates, `restoreDirectFeedOptIn` / `listDirectFeedOptIns` (HTTPS stub, ignore http/`rss:`, keep existing extras), local-catalog refresh outcomes (unchanged validators, not-ready validator bypass, empty parse, blank/`rss:` ids), bounded `getWindow`, TTL recheck on expiry sweep, structural ready cutover, restored-listener remap/merge behavior, sticky RSS tip reuse when a stored id differs from the minted id, and entity toPodcast notification/download/custom-genre flag preservation. +- Existing coverage includes deterministic ID contracts, source matching heuristics, exact legacy URL/GUID upgrade gates, feed-client helpers, episode-supplement matching (including empty titles and distant same-title dates), list merge, tip-resolution (PI id vs negative feed id, blank metadata, match-only baseline rows filled from the feed), supplement artwork fallback (item / channel / show), repository require/failure mapping, disconnect / auto-opt-in predicates, `restoreDirectFeedOptIn` / `listDirectFeedOptIns` (HTTPS stub, ignore http/`rss:`, keep existing extras), local-catalog refresh outcomes (unchanged validators, not-ready validator bypass, empty parse, blank/`rss:` ids), bounded `getWindow`, TTL recheck on expiry sweep, structural ready cutover, restored-listener remap/merge behavior, sticky RSS tip reuse when a stored id differs from the minted id, entity toPodcast notification/download/custom-genre flag preservation, and episode ID clashes (`LocalEpisodeCollisionGuardTest`: cross-show, same-show other key, within one batch, stable reserved IDs, exhaustion; `LocalEpisodeCatalogRepositoryTest`: two shows refreshing one feed keep their own rows). - MockWebServer is available for feed-fetch tests. ```bash diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogic.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogic.kt index f4af99a98..4fdac731f 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogic.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogic.kt @@ -22,7 +22,9 @@ internal object EpisodeSupplementTipLogic { ): Episode { val matched = EpisodeSupplementMatcher.findMatchingBaseline(newestFeedEpisode, knownEpisodes) if (matched != null) { - return matched.copy( + // A match baseline carries only the ID and match fields: the feed fills the rest. + val feed = newestFeedEpisode.toEpisode() + return matched.withBlanksFrom(feed).copy( podcastId = podcastIndexId, podcastTitle = podcastTitle?.takeIf(String::isNotBlank) ?: matched.podcastTitle, podcastImageUrl = podcastImageUrl?.takeIf(String::isNotBlank) ?: matched.podcastImageUrl, @@ -40,3 +42,25 @@ internal object EpisodeSupplementTipLogic { ) } } + +/** + * This episode with every empty field taken from [other], so a match-only baseline row (ID, title, + * audio, date) still yields a tip with description, artwork, length and Podcast 2.0 links. A field + * this episode has always wins, so a full Podcast Index row is unchanged. + */ +internal fun Episode.withBlanksFrom(other: Episode): Episode = copy( + title = title.ifBlank { other.title }, + description = description.ifBlank { other.description }, + audioUrl = audioUrl.ifBlank { other.audioUrl }, + imageUrl = imageUrl?.takeIf(String::isNotBlank) ?: other.imageUrl, + duration = duration.takeIf { it > 0 } ?: other.duration, + publishedDate = publishedDate.takeIf { it > 0L } ?: other.publishedDate, + chaptersUrl = chaptersUrl ?: other.chaptersUrl, + transcriptUrl = transcriptUrl ?: other.transcriptUrl, + transcripts = transcripts ?: other.transcripts, + persons = persons ?: other.persons, + seasonNumber = seasonNumber ?: other.seasonNumber, + episodeNumber = episodeNumber ?: other.episodeNumber, + episodeType = episodeType ?: other.episodeType, + enclosureType = enclosureType ?: other.enclosureType, +) diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LegacyRssSubscriptionUpgrade.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LegacyRssSubscriptionUpgrade.kt index 9d2524153..4e6f3b5d6 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LegacyRssSubscriptionUpgrade.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LegacyRssSubscriptionUpgrade.kt @@ -219,6 +219,12 @@ internal class LegacyRssSubscriptionUpgrader(private val database: BoxLoreDataba if (localRows.isEmpty() || !snapshot.finalFeedUrl.startsWith("https://", ignoreCase = true)) { return null } + // The upgrade keeps the RSS IDs (listener state rides on them), so it can't re-mint around + // another show that already holds one (the same feed listed twice). It stays on RSS instead. + val catalogDao = database.localEpisodeCatalogDao() + if (LocalEpisodeCollisionGuard.takesAnotherShowsId(localRows, target.id) { ids -> catalogDao.findOwners(ids) }) { + return null + } val listenerIds = ( database.listeningHistoryDao().getHistoryForPodcast(source.podcastId).map { it.episodeId } + diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt index 0887c1019..6313d4520 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt @@ -146,13 +146,15 @@ private suspend fun persistParsed( ?.feedOrder ?.takeIf { it != LocalFeedOrder.MIXED } ?: FeedOrderLogic.classify(parsed.episodes.map { it.publishedDate }) - val newestRow = rows.maxByOrNull { it.publishedDate } + var written = rows var storedCount = rows.size var ready = false deps.runInTransaction { - deps.dao.upsertEpisodes(rows) - deps.reconcileListenerState(request.podcastIndexId, rows) - storedCount = maxOf(deps.dao.count(request.podcastIndexId), rows.size) + // Another show reading the same feed may already hold these IDs: never replace its rows. + written = LocalEpisodeCollisionGuard.resolve(rows, rssNamespaceId) { ids -> deps.dao.findOwners(ids) } + deps.dao.upsertEpisodes(written) + deps.reconcileListenerState(request.podcastIndexId, written) + storedCount = maxOf(deps.dao.count(request.podcastIndexId), written.size) ready = LocalCatalogReadyLogic.isReadyToFlip(feedReady = storedCount >= copied) deps.dao.upsertFeed( LocalEpisodeFeedEntity( @@ -171,6 +173,7 @@ private suspend fun persistParsed( ), ) } + val newestRow = written.maxByOrNull { it.publishedDate } return RefreshOutcome.Success( newest = newestRow?.toCatalogEpisode(request.meta), itemCount = storedCount, diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt new file mode 100644 index 000000000..56a5875d0 --- /dev/null +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt @@ -0,0 +1,105 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.LocalEpisodeEntity +import cx.aswin.boxlore.core.database.LocalEpisodeOwner + +/** + * Keeps one episode from replacing another in `local_episodes` (docs/EPISODE_IDS.md §4: no store + * overwrites a different episode, both survive). + * + * `upsertEpisodes` replaces by episode ID, and an incoming row's ID used to be checked only against + * its own show's rows. Two shows that read the same feed (a feed listed twice in Podcast Index) + * mint the same IDs from the same feed namespace, so the second show's refresh took the first + * show's rows: wrong show, title and audio, and a cached download of the other episode. + * + * A row whose ID another (show, catalog key) already holds, in the database or earlier in the same + * batch, gets a reserved local ID instead ([StickyEpisodeIdentity.reservedEpisodeId]); the same + * (show, key) keeps updating its row. A reserved ID is stable: the next refresh finds the stored + * row by its key and reuses it. Call inside the write's transaction. + * + * COMPAT(own-ids): permanent; the Room alias tables of the v2 app build record these later. + */ +internal object LocalEpisodeCollisionGuard { + /** Stays under SQLite's bound-parameter limit. */ + private const val LOOKUP_CHUNK = 500 + + /** Reserved IDs tried per row before the row is left out of this write. */ + const val MAX_RESERVE_ATTEMPTS = 8 + + private data class Owner(val podcastId: String, val key: String) + + /** + * The rows to write: unchanged when their IDs are free or already theirs, otherwise with a + * reserved ID. A row with no free ID after [MAX_RESERVE_ATTEMPTS] is left out rather than + * written over another episode. + */ + suspend fun resolve( + rows: List, + rssNamespaceId: String, + findOwners: suspend (List) -> List, + ): List { + if (rows.isEmpty()) return rows + val stored = storedOwners(rows.map { it.episodeId }, findOwners) + val batch = HashMap() + val out = ArrayList(rows.size) + for (row in rows) { + val mine = Owner(row.podcastId, row.guid) + val id = + if (isFree(row.episodeId, mine, stored, batch)) { + row.episodeId + } else { + reserve(row, mine, rssNamespaceId, stored, batch, findOwners) + } ?: continue + batch[id] = mine + out += if (id == row.episodeId) row else row.copy(episodeId = id) + } + return out + } + + /** + * True when writing [rows] for [podcastId] would replace a row of another show. For writers that + * must keep their IDs (the legacy RSS upgrade carries listener state on them) and so stop + * instead of re-minting. Rows already held by [podcastId] itself don't count. + */ + suspend fun takesAnotherShowsId( + rows: List, + podcastId: String, + findOwners: suspend (List) -> List, + ): Boolean { + val stored = storedOwners(rows.map { it.episodeId }, findOwners) + return rows.any { row -> stored[row.episodeId]?.let { it.podcastId != podcastId } == true } + } + + private suspend fun storedOwners( + ids: List, + findOwners: suspend (List) -> List, + ): MutableMap { + val stored = HashMap() + for (chunk in ids.distinct().chunked(LOOKUP_CHUNK)) { + for (owner in findOwners(chunk)) stored[owner.episodeId] = Owner(owner.podcastId, owner.guid) + } + return stored + } + + private fun isFree(id: String, mine: Owner, stored: Map, batch: Map,): Boolean = + (batch[id] ?: stored[id]).let { it == null || it == mine } + + @Suppress("LongParameterList") + private suspend fun reserve( + row: LocalEpisodeEntity, + mine: Owner, + rssNamespaceId: String, + stored: MutableMap, + batch: Map, + findOwners: suspend (List) -> List, + ): String? { + for (attempt in 1..MAX_RESERVE_ATTEMPTS) { + val candidate = StickyEpisodeIdentity.reservedEpisodeId(rssNamespaceId, row.podcastId, row.guid, attempt) + if (candidate !in stored) { + for (owner in findOwners(listOf(candidate))) stored[owner.episodeId] = Owner(owner.podcastId, owner.guid) + } + if (isFree(candidate, mine, stored, batch)) return candidate + } + return null + } +} diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/StickyEpisodeIdentity.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/StickyEpisodeIdentity.kt index d35494dd6..b4d61b97d 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/StickyEpisodeIdentity.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/StickyEpisodeIdentity.kt @@ -50,6 +50,20 @@ object StickyEpisodeIdentity { ) } + /** + * A local ID for a row whose usual ID another (show, key) already holds + * ([LocalEpisodeCollisionGuard]). Negative like every minted ID, and the same for the same + * (namespace, show, key, attempt), so a refresh that hits the same clash picks the same ID. + */ + fun reservedEpisodeId(rssNamespaceId: String, podcastId: String, catalogKey: String, attempt: Int,): String = + RssIdGenerator.episodeIdForPodcast( + podcastId = rssNamespaceId, + guid = "$catalogKey\u0000reserved:$podcastId:$attempt", + enclosureUrl = null, + publishedDate = 0L, + title = "", + ) + fun firstWinsExisting(seenKeys: MutableSet, key: String,): Boolean { if (key in seenKeys) return false seenKeys += key diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt index 95dd1039f..15565f659 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt @@ -1,6 +1,7 @@ package cx.aswin.boxlore.core.rss import cx.aswin.boxlore.core.database.RssEpisodeEntity +import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.testing.TestFixtures import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -131,6 +132,41 @@ class EpisodeSupplementTipLogicTest { assertEquals("Host", tip.podcastArtist) } + @Test + fun `a match-only baseline row keeps its id and takes the rest from the feed`() { + // /episodes/match rows carry only the ID, title, audio and date. + val feed = + rssEpisode(episodeId = "-99", title = "Fresh", audioUrl = "https://cdn.example/fresh.mp3", publishedDate = 200L) + .copy(description = "From the feed", imageUrl = "https://img/ep.jpg", chaptersUrl = "https://c/ch.json", episodeNumber = 12) + val lean = Episode(id = "42", title = "Fresh", description = "", audioUrl = "https://cdn.example/fresh.mp3", podcastId = "pi-1", publishedDate = 200L) + + val tip = EpisodeSupplementTipLogic.resolveNewestTip(feed, "pi-1", listOf(lean), "Show", "https://img", "News", "Host") + + assertEquals("42", tip.id) + assertEquals("From the feed", tip.description) + assertEquals("https://img/ep.jpg", tip.imageUrl) + assertEquals(60, tip.duration) + assertEquals("https://c/ch.json", tip.chaptersUrl) + assertEquals(12, tip.episodeNumber) + assertEquals("Show", tip.podcastTitle) + } + + @Test + fun `a full baseline row keeps its own fields`() { + val feed = + rssEpisode(episodeId = "-99", title = "Fresh", audioUrl = "https://cdn.example/fresh.mp3", publishedDate = 200L) + .copy(description = "From the feed", imageUrl = "https://img/feed.jpg") + val full = + Episode( + id = "42", title = "Fresh", description = "From Podcast Index", audioUrl = "https://cdn.example/fresh.mp3", + imageUrl = "https://img/pi.jpg", duration = 1800, podcastId = "pi-1", publishedDate = 200L, + ) + + val tip = EpisodeSupplementTipLogic.resolveNewestTip(feed, "pi-1", listOf(full), null, null, null, null) + + assertEquals(full.copy(podcastId = "pi-1"), tip) + } + private fun rssEpisode(episodeId: String, title: String, audioUrl: String, publishedDate: Long,): RssEpisodeEntity = RssEpisodeEntity( episodeId = episodeId, podcastId = "rss:unused", diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt index 3ec84d220..a0e71a3d1 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt @@ -4,6 +4,8 @@ import cx.aswin.boxlore.core.database.LocalEpisodeCatalogDao import cx.aswin.boxlore.core.database.LocalEpisodeEntity import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity import cx.aswin.boxlore.core.database.LocalEpisodeIdentity +import cx.aswin.boxlore.core.database.LocalEpisodeOwner +import cx.aswin.boxlore.core.database.RssEpisodeEntity import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -331,6 +333,35 @@ class LocalEpisodeCatalogRepositoryTest { assertEquals(0, fetches) } + @Test + fun twoShowsReadingTheSameFeedKeepTheirOwnEpisodes() = runTest { + // A feed listed twice in Podcast Index: both shows mint the same IDs from one feed namespace, + // and the second refresh used to replace the first show's row (wrong show, title and audio). + val dao = FakeCatalogDao() + val repo = catalogRepo(dao = dao, feedClient = SharedFeedClient()) + val idsAfter = mutableListOf>() + for (show in listOf("100", "200", "200", "100")) { + val outcome = + repo.refresh( + LocalEpisodeCatalogPort.RefreshRequest( + podcastIndexId = show, + feedUrl = "https://feeds.example/shared.xml", + reason = LocalEpisodeCatalogPort.RefreshReason.MANUAL, + ), + ) + assertTrue(outcome is RefreshOutcome.Success) + idsAfter += dao.episodes.map { it.episodeId }.toSet() + } + + assertEquals(listOf("100", "200"), dao.episodes.map { it.podcastId }.sorted()) + val first = dao.episodes.single { it.podcastId == "100" } + val second = dao.episodes.single { it.podcastId == "200" } + assertEquals(SharedFeedClient.mintedId(), first.episodeId) + assertTrue(second.episodeId != first.episodeId && second.episodeId.toLong() < 0L) + assertEquals(first.guid, second.guid, "both keep the episode itself") + assertEquals(idsAfter[1], idsAfter[3], "IDs stay put on later refreshes") + } + private fun catalogRepo( dao: FakeCatalogDao = FakeCatalogDao(), isFeedUnchanged: suspend (String, String?, String?) -> Boolean = { _, _, _ -> false }, @@ -366,6 +397,56 @@ class LocalEpisodeCatalogRepositoryTest { ) } + /** One episode in one feed, read by every show that points at it. */ + private class SharedFeedClient : RssFeedClient() { + override suspend fun fetch(url: String) = RssFetchResult(finalUrl = url, etag = null, lastModified = null, body = ByteArray(1)) + + override suspend fun parse(feedUrl: String, bytes: ByteArray, podcastId: String,) = ParsedRssFeed( + title = "Shared", + author = "", + description = null, + imageUrl = null, + genre = null, + podcastType = "episodic", + podcastGuid = null, + declaredUpdatedAt = null, + episodes = + listOf( + RssEpisodeEntity( + episodeId = mintedId(), + podcastId = podcastId, + guid = GUID, + title = "Shared episode", + description = "", + audioUrl = "https://cdn.example/shared.mp3", + imageUrl = null, + duration = 60, + publishedDate = 10L, + chaptersUrl = null, + transcriptUrl = null, + transcripts = null, + persons = null, + seasonNumber = null, + episodeNumber = null, + episodeType = null, + enclosureType = null, + ), + ), + ) + + companion object { + const val GUID = "shared-guid" + + fun mintedId(): String = RssIdGenerator.episodeIdForPodcast( + podcastId = RssIdGenerator.podcastId("https://feeds.example/shared.xml"), + guid = GUID, + enclosureUrl = "https://cdn.example/shared.mp3", + publishedDate = 10L, + title = "Shared episode", + ) + } + } + private class FakeCatalogDao : LocalEpisodeCatalogDao { val feeds = mutableMapOf() val episodes = mutableListOf() @@ -392,6 +473,10 @@ class LocalEpisodeCatalogRepositoryTest { LocalEpisodeIdentity(it.episodeId, it.guid, it.audioUrl) } + override suspend fun findOwners(episodeIds: List): List = episodes + .filter { it.episodeId in episodeIds } + .map { LocalEpisodeOwner(it.episodeId, it.podcastId, it.guid) } + override suspend fun getNewestPage(podcastId: String, limit: Int, offset: Int,): List = episodes .filter { it.podcastId == podcastId } .sortedWith(compareByDescending { it.publishedDate }.thenBy { it.episodeId }) diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuardTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuardTest.kt new file mode 100644 index 000000000..3f7f41936 --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuardTest.kt @@ -0,0 +1,111 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.LocalEpisodeEntity +import cx.aswin.boxlore.core.database.LocalEpisodeOwner +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LocalEpisodeCollisionGuardTest { + private val namespace = "rss:shared-feed" + + /** A `local_episodes` stand-in: episode ID → owner, replaced like Room's REPLACE. */ + private val table = linkedMapOf() + private val owners: suspend (List) -> List = { ids -> ids.mapNotNull(table::get) } + + private fun store(rows: List) { + for (r in rows) table[r.episodeId] = LocalEpisodeOwner(r.episodeId, r.podcastId, r.guid) + } + + @Test + fun anotherShowsIdGetsAReservedIdAndBothEpisodesSurvive() = runTest { + store(listOf(row("-11", show = "100", key = "guid-a"))) + val written = LocalEpisodeCollisionGuard.resolve(listOf(row("-11", show = "200", key = "guid-a")), namespace, owners) + + val reserved = written.single().episodeId + assertNotEquals("-11", reserved) + assertTrue(reserved.toLong() < 0L, "reserved IDs stay negative") + assertEquals("200", written.single().podcastId) + store(written) + assertEquals("100", table.getValue("-11").podcastId) + assertEquals("200", table.getValue(reserved).podcastId) + } + + @Test + fun theSameShowAndKeyKeepsUpdatingItsRow() = runTest { + store(listOf(row("-11", show = "100", key = "guid-a"))) + val written = LocalEpisodeCollisionGuard.resolve(listOf(row("-11", show = "100", key = "guid-a", title = "Renamed")), namespace, owners) + assertEquals("-11", written.single().episodeId) + assertEquals("Renamed", written.single().title) + } + + @Test + fun theSameShowWithAnotherKeyIsAClashToo() = runTest { + store(listOf(row("77", show = "100", key = "old-guid"))) + val written = LocalEpisodeCollisionGuard.resolve(listOf(row("77", show = "100", key = "new-guid")), namespace, owners) + assertNotEquals("77", written.single().episodeId) + } + + @Test + fun twoRowsOfOneBatchNeverShareAnId() = runTest { + val written = + LocalEpisodeCollisionGuard.resolve( + listOf(row("-11", show = "100", key = "guid-a"), row("-11", show = "100", key = "guid-b")), + namespace, + owners, + ) + assertEquals(2, written.map { it.episodeId }.toSet().size) + assertEquals("-11", written.first().episodeId) + } + + @Test + fun aReservedIdIsStableAcrossRefreshes() = runTest { + store(listOf(row("-11", show = "100", key = "guid-a"))) + val first = LocalEpisodeCollisionGuard.resolve(listOf(row("-11", show = "200", key = "guid-a")), namespace, owners) + store(first) + val again = LocalEpisodeCollisionGuard.resolve(listOf(row("-11", show = "200", key = "guid-a")), namespace, owners) + assertEquals(first.single().episodeId, again.single().episodeId) + } + + @Test + fun aRowWithNoFreeIdIsLeftOutInsteadOfOverwriting() = runTest { + store(listOf(row("-11", show = "100", key = "guid-a"))) + for (attempt in 1..LocalEpisodeCollisionGuard.MAX_RESERVE_ATTEMPTS) { + val id = StickyEpisodeIdentity.reservedEpisodeId(namespace, "200", "guid-a", attempt) + table[id] = LocalEpisodeOwner(id, "300", "other") + } + val written = LocalEpisodeCollisionGuard.resolve(listOf(row("-11", show = "200", key = "guid-a")), namespace, owners) + assertTrue(written.isEmpty()) + } + + @Test + fun theLegacyUpgradeCheckIgnoresItsOwnShowsRows() = runTest { + store(listOf(row("-11", show = "100", key = "guid-a"))) + assertFalse(LocalEpisodeCollisionGuard.takesAnotherShowsId(listOf(row("-11", show = "100", key = "guid-x")), "100", owners)) + assertTrue(LocalEpisodeCollisionGuard.takesAnotherShowsId(listOf(row("-11", show = "200", key = "guid-a")), "200", owners)) + assertFalse(LocalEpisodeCollisionGuard.takesAnotherShowsId(listOf(row("-12", show = "200", key = "guid-b")), "200", owners)) + } + + private fun row(id: String, show: String, key: String, title: String = "Ep",) = LocalEpisodeEntity( + episodeId = id, + podcastId = show, + guid = key, + title = title, + description = "", + audioUrl = "https://cdn.example/$key.mp3", + imageUrl = null, + duration = 60, + publishedDate = 10L, + chaptersUrl = null, + transcriptUrl = null, + transcripts = null, + persons = null, + seasonNumber = null, + episodeNumber = null, + episodeType = null, + enclosureType = null, + ) +} diff --git a/feature/info/README.md b/feature/info/README.md index 1d9d0026f..69b4eabfa 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -94,7 +94,7 @@ src/main/java/cx/aswin/boxlore/feature/info/ - `ArtworkInfoThemeHandoffTest` exercises the actual loader/theme wrapper with a controlled Compose clock: inherited first-frame colors, a long content load, isolation from transient shell colors, missing-art fallback and artwork opt-out, including navbar agreement and a fresh Back handoff while the returning composition is still retained. - `ArtworkInfoThemeLayoutTest` uses hermetic Robolectric/Compose JVM rendering to check top-aligned header geometry and visible body pixels after scrolling and making the header opaque. It exercises the actual theme wrapper without an app ViewModel or network. - `ArtworkBackdropLogicTest` checks monotonic fade strength and a fully transparent tail before layer clipping, and that the backdrop's strength stays within bounds while keeping header text at 4.5:1 for light and dark grounds. Designsystem tests cover palette retarget continuity, intermediate contrast, synchronized navbar surfaces and Pure preservation. -- Existing coverage includes assembler behavior, catalog port behavior and errors, offline merge logic, listening-progress mapping, duration formatting, metadata chip logic, feed grouping, selection range/order logic, toolbar logic, HTML stripping, podcast info ViewModel logic, pull-to-refresh target (RSS vs subscribed direct feed vs opted-in direct feed), subscription property/toggle preservation across pull-to-refresh and late API enrichment, episode-supplement merge/eligibility, episode list artwork fallback, and `PodcastInfoSupplementSupport` refresh / PI-only baseline / auto-opt-in / search union. Home pin persistence, capacity, and unsubscribe cleanup are covered in `:core:prefs` (`HomePinnedShowsTest`, `UserPreferencesRepositoryTest`) rather than constructing `PodcastInfoViewModel`. +- Existing coverage includes assembler behavior, catalog port behavior and errors, offline merge logic, listening-progress mapping, duration formatting, metadata chip logic, feed grouping, selection range/order logic, toolbar logic, HTML stripping, podcast info ViewModel logic, pull-to-refresh target (RSS vs subscribed direct feed vs opted-in direct feed), subscription property/toggle preservation across pull-to-refresh and late API enrichment, episode-supplement merge/eligibility, episode list artwork fallback, and `PodcastInfoSupplementSupport` refresh / PI-only baseline / auto-opt-in / search union. The repository-backed support takes its baseline from `PodcastRepository.loadPiEpisodesForBaseline` (up to 1,000, strict: a failure stops the refresh instead of matching against an empty list). Home pin persistence, capacity, and unsubscribe cleanup are covered in `:core:prefs` (`HomePinnedShowsTest`, `UserPreferencesRepositoryTest`) rather than constructing `PodcastInfoViewModel`. - `SubscriptionToolbarMotionTest` covers genre confirmation and its heart fallback, initial states, successful confirmation, reversal, disabled animations, staggered reveal, bounded geometry and accessibility readiness. - `PodcastEpisodeActionsTest` covers resumed progress, completed and unstarted states, invalid progress and clamping before rendering the separate progress strip. - `MoreFromEpisodeSelectionTest` covers newest-first ordering, current-episode exclusion before the five-item limit, duplicate IDs, stable ordering for equal dates, and short/empty catalogs. diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt index b7769173a..933c04b09 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt @@ -49,16 +49,8 @@ internal class PodcastInfoSupplementSupport( { id, limit, offset, sort -> repository.getEpisodesPaginated(id, limit, offset, sort) }, - { id -> - repository - .getEpisodesPaginated( - feedId = id, - limit = SUPPLEMENT_BASELINE_LIMIT, - offset = 0, - sort = "oldest", - mergeSupplements = false, - ).episodes - }, + // The strict match baseline (up to 1,000, PI rows only); a page is capped at 100. + { id -> repository.loadPiEpisodesForBaseline(id, SUPPLEMENT_BASELINE_LIMIT) }, { id -> repository.localEpisodeCatalog?.isReady(id) == true }, ) From c11d1a270bf6fc8c9d8886ccc5d0d22a3a6212e9 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Thu, 22 Oct 2026 20:56:08 +0530 Subject: [PATCH 2/4] refactor(rss): read-only owner map in the legacy upgrade check --- .../cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt index 56a5875d0..9738e8bd8 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt @@ -66,7 +66,7 @@ internal object LocalEpisodeCollisionGuard { podcastId: String, findOwners: suspend (List) -> List, ): Boolean { - val stored = storedOwners(rows.map { it.episodeId }, findOwners) + val stored: Map = storedOwners(rows.map { it.episodeId }, findOwners) return rows.any { row -> stored[row.episodeId]?.let { it.podcastId != podcastId } == true } } From 96c7a05f6154ab999b73a17080e2c8b723abff26 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Thu, 22 Oct 2026 21:12:51 +0530 Subject: [PATCH 3/4] fix(rss): fail a refresh that can't place every row; cover findOwners and the Info baseline --- .../LocalEpisodeCatalogDaoOwnersTest.kt | 67 +++++++++++++++++++ core/rss/README.md | 2 +- .../core/rss/LocalEpisodeCatalogRefresh.kt | 7 ++ .../core/rss/LocalEpisodeCollisionGuard.kt | 2 +- .../rss/LocalEpisodeCatalogRepositoryTest.kt | 28 ++++++++ .../info/PodcastInfoSupplementSupport.kt | 11 ++- .../info/PodcastInfoSupplementSupportTest.kt | 30 +++++++++ 7 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 core/database/src/test/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDaoOwnersTest.kt diff --git a/core/database/src/test/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDaoOwnersTest.kt b/core/database/src/test/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDaoOwnersTest.kt new file mode 100644 index 000000000..eee20c59d --- /dev/null +++ b/core/database/src/test/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDaoOwnersTest.kt @@ -0,0 +1,67 @@ +package cx.aswin.boxlore.core.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** `findOwners` through real Room: the check a catalog write runs before it may replace a row. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class LocalEpisodeCatalogDaoOwnersTest { + private lateinit var database: BoxLoreDatabase + private lateinit var dao: LocalEpisodeCatalogDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + dao = database.localEpisodeCatalogDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun findOwnersReportsTheShowAndKeyOfEachRequestedId() = runTest { + dao.upsertEpisodes(listOf(row("-11", show = "100", key = "guid-a"), row("-12", show = "100", key = "guid-b"), row("77", show = "200", key = "guid-c"))) + + val owners = dao.findOwners(listOf("-11", "77", "-99")).sortedBy { it.episodeId } + + assertEquals( + listOf(LocalEpisodeOwner("-11", "100", "guid-a"), LocalEpisodeOwner("77", "200", "guid-c")), + owners, + ) + assertTrue("an unknown ID has no owner", dao.findOwners(listOf("-99")).isEmpty()) + } + + private fun row(id: String, show: String, key: String) = LocalEpisodeEntity( + episodeId = id, + podcastId = show, + guid = key, + title = "Ep $id", + description = "", + audioUrl = "https://cdn.example/$id.mp3", + imageUrl = null, + duration = 60, + publishedDate = 10L, + chaptersUrl = null, + transcriptUrl = null, + transcripts = null, + persons = null, + seasonNumber = null, + episodeNumber = null, + episodeType = null, + enclosureType = null, + ) +} diff --git a/core/rss/README.md b/core/rss/README.md index 98c0a2b58..72b1f81d3 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -79,7 +79,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - RSS episode IDs are strings encoding deterministic negative `Long` values to avoid collision with positive Podcast Index IDs. - RSS episode and podcast rows are persisted in the main database owned by `:core:database`. - Exact legacy upgrades retain the old RSS row and episodes as an unsubscribed compatibility alias; the active PI row owns an equivalent ready local catalog using the same negative episode ids. -- Local catalog writes never replace another episode (`LocalEpisodeCollisionGuard`, [docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md) §4). Two shows that read the same feed (one feed listed twice in Podcast Index) mint the same IDs from one feed namespace; inside the refresh transaction, a row whose ID another (show, catalog key) holds, or that an earlier row of the batch took, gets a reserved negative ID (`StickyEpisodeIdentity.reservedEpisodeId`, stable across refreshes), while the same (show, key) keeps updating its row. A row with no free ID after 8 tries is left out of that write. The legacy RSS upgrade keeps its IDs for listener state, so it stays on RSS instead when another show holds one. +- Local catalog writes never replace another episode (`LocalEpisodeCollisionGuard`, [docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md) §4). Two shows that read the same feed (one feed listed twice in Podcast Index) mint the same IDs from one feed namespace; inside the refresh transaction, a row whose ID another (show, catalog key) holds, or that an earlier row of the batch took, gets a reserved negative ID (`StickyEpisodeIdentity.reservedEpisodeId`, stable across refreshes), while the same (show, key) keeps updating its row. If a row finds no free ID after 8 tries, the refresh writes nothing and fails, so a partial catalog is never marked complete. The legacy RSS upgrade keeps its IDs for listener state, so it stays on RSS instead when another show holds one. - Package root is `cx.aswin.boxlore.core.rss`. ## Testing notes diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt index 6313d4520..fa3ff9353 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt @@ -149,9 +149,15 @@ private suspend fun persistParsed( var written = rows var storedCount = rows.size var ready = false + var incomplete = false deps.runInTransaction { // Another show reading the same feed may already hold these IDs: never replace its rows. written = LocalEpisodeCollisionGuard.resolve(rows, rssNamespaceId) { ids -> deps.dao.findOwners(ids) } + if (written.size < rows.size) { + // A row found no free ID: write nothing rather than mark a partial catalog complete. + incomplete = true + return@runInTransaction + } deps.dao.upsertEpisodes(written) deps.reconcileListenerState(request.podcastIndexId, written) storedCount = maxOf(deps.dao.count(request.podcastIndexId), written.size) @@ -173,6 +179,7 @@ private suspend fun persistParsed( ), ) } + if (incomplete) return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE) val newestRow = written.maxByOrNull { it.publishedDate } return RefreshOutcome.Success( newest = newestRow?.toCatalogEpisode(request.meta), diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt index 9738e8bd8..b83a39032 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt @@ -31,7 +31,7 @@ internal object LocalEpisodeCollisionGuard { /** * The rows to write: unchanged when their IDs are free or already theirs, otherwise with a * reserved ID. A row with no free ID after [MAX_RESERVE_ATTEMPTS] is left out rather than - * written over another episode. + * written over another episode; the refresh then writes nothing and fails. */ suspend fun resolve( rows: List, diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt index a0e71a3d1..cceb077ef 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt @@ -362,6 +362,34 @@ class LocalEpisodeCatalogRepositoryTest { assertEquals(idsAfter[1], idsAfter[3], "IDs stay put on later refreshes") } + @Test + fun aRowWithNoFreeIdFailsTheRefreshAndWritesNothing() = runTest { + // Every ID the second show could take already belongs to a third show. + val dao = FakeCatalogDao() + val namespace = RssIdGenerator.podcastId("https://feeds.example/shared.xml") + val taken = + listOf(SharedFeedClient.mintedId()) + + (1..LocalEpisodeCollisionGuard.MAX_RESERVE_ATTEMPTS).map { + StickyEpisodeIdentity.reservedEpisodeId(namespace, "200", SharedFeedClient.GUID, it) + } + dao.episodes += taken.map { localEpisode(it).copy(podcastId = "300", guid = "other-$it") } + val repo = catalogRepo(dao = dao, feedClient = SharedFeedClient()) + + val outcome = + repo.refresh( + LocalEpisodeCatalogPort.RefreshRequest( + podcastIndexId = "200", + feedUrl = "https://feeds.example/shared.xml", + reason = LocalEpisodeCatalogPort.RefreshReason.MANUAL, + ), + ) + + assertTrue(outcome is RefreshOutcome.Failure) + assertTrue(dao.episodes.none { it.podcastId == "200" }) + assertEquals(taken.size, dao.episodes.count { it.podcastId == "300" }) + assertNull(dao.feeds["200"], "no catalog row marked complete") + } + private fun catalogRepo( dao: FakeCatalogDao = FakeCatalogDao(), isFeedUnchanged: suspend (String, String?, String?) -> Boolean = { _, _, _ -> false }, diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt index 933c04b09..bce52d0b6 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt @@ -49,8 +49,7 @@ internal class PodcastInfoSupplementSupport( { id, limit, offset, sort -> repository.getEpisodesPaginated(id, limit, offset, sort) }, - // The strict match baseline (up to 1,000, PI rows only); a page is capped at 100. - { id -> repository.loadPiEpisodesForBaseline(id, SUPPLEMENT_BASELINE_LIMIT) }, + repositoryBaseline { id, limit -> repository.loadPiEpisodesForBaseline(id, limit) }, { id -> repository.localEpisodeCatalog?.isReady(id) == true }, ) @@ -265,6 +264,14 @@ internal class PodcastInfoSupplementSupport( */ const val SUPPLEMENT_BASELINE_LIMIT = 1000 private const val DISPLAY_PAGE_SIZE = 20 + + /** + * The repository-backed baseline: [PodcastRepository.loadPiEpisodesForBaseline] for up to + * [SUPPLEMENT_BASELINE_LIMIT] PI rows (a page is capped at 100). It throws on failure, so + * [refreshMissingEpisodes] stops before matching the feed against an empty list. + */ + internal fun repositoryBaseline(load: suspend (podcastId: String, limit: Int) -> List): suspend (String) -> List = + { id -> load(id, SUPPLEMENT_BASELINE_LIMIT) } private const val OLDEST_PAGE_SIZE = 50 } } diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupportTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupportTest.kt index ba15c4353..a03b70481 100644 --- a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupportTest.kt +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupportTest.kt @@ -300,6 +300,36 @@ class PodcastInfoSupplementSupportTest { assertEquals(DirectFeedChipState.Hidden, hidden.directFeedChip) } + @Test + fun `the repository baseline asks for 1000 rows and a failure stops the feed refresh`() = runTest { + val asked = mutableListOf>() + val ok = + PodcastInfoSupplementSupport.repositoryBaseline { id, limit -> + asked += id to limit + listOf(TestFixtures.episode(id = "pi-1")) + } + assertEquals(listOf("pi-1"), ok("123").map { it.id }) + assertEquals(listOf("123" to PodcastInfoSupplementSupport.SUPPLEMENT_BASELINE_LIMIT), asked) + assertEquals(1000, PodcastInfoSupplementSupport.SUPPLEMENT_BASELINE_LIMIT) + + val port = FakePort(optedIn = mutableSetOf()) + val support = + PodcastInfoSupplementSupport( + episodeSupplementPort = port, + loadPage = { _, _, _, _ -> PodcastRepository.EpisodePage(emptyList(), hasMore = false) }, + loadPiBaseline = PodcastInfoSupplementSupport.repositoryBaseline { _, _ -> error("PI baseline HTTP 500") }, + ) + val state = + PodcastInfoUiState.Success( + podcast = TestFixtures.podcast(id = "123").copy(feedUrl = "https://feeds.example/show.xml"), + episodes = emptyList(), + isSubscribed = true, + ) + val thrown = runCatching { support.refreshMissingEpisodes(state, announceResult = true) }.exceptionOrNull() + assertTrue(thrown is IllegalStateException, "the caller shows its error state") + assertTrue(port.optedIn.isEmpty(), "the feed was never matched against an empty baseline") + } + private class FakePort( var optedIn: MutableSet, ) : EpisodeSupplementPort { From ddd97583eb348b5cc125e57aceb16e3715126ee2 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Thu, 22 Oct 2026 21:26:10 +0530 Subject: [PATCH 4/4] fix(catalog): confirm an empty match list with the page path --- core/catalog/README.md | 2 +- .../boxlore/core/catalog/PodcastRepository.kt | 12 ++++++++++-- .../core/catalog/PodcastRepositoryCatalogTest.kt | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/core/catalog/README.md b/core/catalog/README.md index 89d67abb7..78cab8a17 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -14,7 +14,7 @@ Failed consent writes keep activation off, and local notification flags prevent - RSS notification activation requires explicit public-feed disclosure acceptance through `setNotificationsEnabled(..., acceptRssDisclosure = true, disclosedFeedUrl = savedUrl)`. `DeviceRssNotificationConsent` stores only URL fingerprints in `noBackupFilesDir`; changed URLs, restore and remote cloud settings cannot supply acceptance. Canonical `rss:` IDs require disclosure even with stale source metadata. Refused or revoked activation also removes this device's old registration; a library cleared during acceptance cannot publish a deleted show. Notification settings remain independent of auto-download. The shared checker receives only explicitly accepted public feeds. `TrackedPodcastRtdbLogic.topic` maps `rss:` to FCM-safe `rss_` while retaining canonical IDs in payloads. JSON/cloud RSS restore retains the original saved show ID across redirects. - RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. -- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` is the strict PI baseline for feed-episode matching (first catalog persist, library restore, Info, push hydration): up to 1,000 newest episodes as match fields only from `GET episodes/match` (`MAX_MATCH_BASELINE`), so a show with more than 100 episodes keeps its newer episodes on the IDs history, progress and downloads use. It falls back to the old oldest-100 page when the route fails or the `useEpisodeMatchBaseline` off switch (a PostHog kill switch passed by the app) is off. Full pages keep the 100 cap. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). +- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` is the strict PI baseline for feed-episode matching (first catalog persist, library restore, Info, push hydration): up to 1,000 newest episodes as match fields only from `GET episodes/match` (`MAX_MATCH_BASELINE`), so a show with more than 100 episodes keeps its newer episodes on the IDs history, progress and downloads use. It falls back to the old oldest-100 page when the route fails, returns no episodes, or the `useEpisodeMatchBaseline` off switch (a PostHog kill switch passed by the app) is off. Full pages keep the 100 cap. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). - `syncSubscriptions` (`POST /sync`) is PI-only for shows that are **not** catalog-ready and **not** still on extras opt-in. The PI ids go out in chunks of 20 (`PI_SYNC_CHUNK_SIZE`), at most two calls at a time (`PI_SYNC_PARALLELISM`), because the API serves only the first 40 ids of a call: whole-library callers (Smart Downloads, backup restore) used to lose every show past the 40th. A failed chunk drops only its own shows' tips. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). - Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead` / Meili) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: one CF embed → Qdrant podcasts + episodes; additive `feeds` + legacy `items`). - `logic.mergeShowSearchResults` dedupes Meili vs hybrid by id / itunes / feed URL. Missing/generic typeahead genres are filled from matching hybrid results when their known genre agrees; title-only matches and conflicting genres are not enrichment evidence. Existing catalog identity, other metadata, order and grouping stay unchanged. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt index 2e1271faf..874a077f8 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt @@ -307,11 +307,19 @@ class PodcastRepository( response.body()!!.items.mapNotNull { mapToEpisode(it) } } - /** `/episodes/match` as baseline episodes, or null to use the page path (HTTP or network error). */ + /** + * `/episodes/match` as baseline episodes, or null to use the page path: an HTTP or network + * error, or an empty list (the page path confirms a show with no episodes, so a misbehaving + * route can never leave a feed matched against nothing). + */ private fun loadMatchBaseline(resolvedId: String, limit: Int): List? = try { val response = api.getEpisodeMatchList(publicKey, resolvedId, limit.coerceIn(1, MAX_MATCH_BASELINE)).execute() val body = response.body() - if (response.isSuccessful && body != null) body.items.mapNotNull { it.toBaselineEpisode(resolvedId) } else null + if (response.isSuccessful && body != null) { + body.items.mapNotNull { it.toBaselineEpisode(resolvedId) }.takeIf { it.isNotEmpty() } + } else { + null + } } catch (e: java.io.IOException) { android.util.Log.w("PodcastRepository", "match baseline failed for $resolvedId; using the page path", e) null diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt index e5d04e17b..803036057 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryCatalogTest.kt @@ -316,6 +316,21 @@ class PodcastRepositoryCatalogTest { assertTrue(page.startsWith("/episodes?") && page.contains("limit=100") && page.contains("sort=oldest"), page) } + @Test + fun `an empty match list is confirmed by the page path`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"items":[]}""")) + server.enqueue( + MockResponse().setHeader("Content-Type", "application/json") + .setBody("""{"items":[{"id":11,"title":"Old","enclosureUrl":"https://cdn.example/11.mp3","datePublished":1}],"hasMore":false}"""), + ) + + val baseline = repository.loadPiEpisodesForBaseline("90001", limit = 1000) + + assertEquals(listOf("11"), baseline.map { it.id }) + assertTrue(server.takeRequest().path!!.startsWith("/episodes/match")) + assertTrue(server.takeRequest().path!!.startsWith("/episodes?")) + } + @Test fun `match baseline off switch keeps the page path`() = runTest(testDispatcher) { val off =