hzz docs resolver-assembly.md
14 kB · 207 lines
Markdown
at main

Production resolver assembly #

Scope #

app/src/main/kotlin/tv/streamer/app/resolver/ builds the app's SourceResolver from the accounts the TV holds and rebuilds it whenever that configuration changes. It is the glue the two landed backends were missing: StremioBackend (docs/stremio-backend.md) per configured addon and SubscribedBackend (docs/subscribed.md) over TMDB where-to-watch. Resolution runs on the device, per the spec's "on-device first" decision for the source resolver.

ResolverModule.kt ResolverModule.assembly() is a Hilt @Singleton provider. Its one caller is the detail screen (docs/detail-screen.md): DetailActivity injects ResolverAssembly, resolves a title on open and reports playback outcomes to the resolver instance that answered.

Pieces #

  • ResolverAssembly.kt ResolverAssembly exposes resolver: StateFlow<SourceResolver>, a new SourceResolverImpl after every configuration change. backends(configuration) adds one StremioBackend per addon whose manifest URL AddonEndpoints.fromManifestUrl accepts, on a client from ProviderHttp.client(providerId = <source key>), then one SubscribedBackend when a TMDB key exists, over TmdbClient(JsonHttp(<"tmdb" client>), key). Addons come first, in account-id order. One TmdbClient per build serves both the Subscribed backend and, through ExternalIdMapping, the addons' id lookup: the ids constructor argument is a factory called on every build with that mapping, or null when no key exists.
  • AddonConfiguration.kt ConfiguredSources turns LibraryStore.accounts plus CredentialStore.read into a SourceConfiguration (Addon list and an optional TMDB key).
  • RoomStremioIds.kt RoomStremioIds implements the StremioIds seam from the IMDb id cached in Room: tt<imdb> for a movie or series, and for an episode the series' cached IMDb id with the season and episode numbers from the MediaRef — addons address episodes by the series id, so the episode's own IMDb id is not consulted. When no IMDb id is cached (or the cached value does not match tt[0-9]+) and a TMDB mapping was given, it asks ExternalIdMapping.imdbId for the movie or series and writes the answer back onto the cached item, so the next resolve sends no TMDB request. A title with no cached item gets the id but is not stored, because an item cannot be saved without its title. A TMDB error or an answer without a valid IMDb id is null, never an error: the addon is not at fault, so its source health is untouched, and nothing is cached, so the next resolve asks again. Null, and so no addon request, when no id is cached and no TMDB key is configured.
  • PackageInstalledApps.kt PackageInstalledApps implements InstalledApps through PackageManager.getApplicationInfo(...).enabled; an absent package is not installed. It is asked at verify time, so a package removed since discovery is not offered.
  • ResolverModule.kt ResolverModule wires these to StorageStartup and CredentialStore.open(context) on a SupervisorJob() + Dispatchers.Default scope. CredentialStore is deliberately not bound in Hilt.

Credential conventions #

Both account types keep their secret in the credential store (docs/credential-store.md), referenced from the account row's credentialRef.

  • A STREMIO_ADDON account's credential bytes are the UTF-8 manifest URL and nothing else.
  • A TMDB account's credential bytes are the UTF-8 API key and nothing else.

There is no envelope, because the credential store already authenticates and bounds the bytes and a second framing would have to be migrated. AddonConfiguration.encode() produces the bytes. The Household Sources routes (sources-api.md) are the credential entry for both types, and their HouseholdSourceStore must write exactly this form.

An account whose secret cannot be read is skipped without affecting the others: a missing or malformed credentialRef, ReentryRequired, a CredentialStoreException, bytes that are not valid UTF-8, or a blank value. Where several TMDB accounts exist, the lowest account id wins, because TmdbClient takes one app-level key. Other provider types are ignored.

Source keys #

An addon's source key is account-<id> (AddonConfiguration.sourceKey()), so its backend id is stremio:account-<id>. The key is opaque and never derived from the manifest URL, which may carry a token; it also names the addon's HTTP client. It satisfies the backend's [A-Za-z0-9_-]{1,128} rule.

Rebuilds #

  • When. ConfiguredSources.configuration applies distinctUntilChanged() to the derived SourceConfiguration, so the resolver is rebuilt only when the addon list or the TMDB key changes. Editing an unrelated account (a Trakt label, an authorization flag) does not rebuild it or drop its cache. A change of the Household region (HomeRepository.region, wired in ResolverModule) rebuilds it too, on a new TmdbClient for that region, so where-to-watch answers follow the region without a restart.
  • What is shared. One SourceHealthStore, one AnswerCache and one InMemoryWantedStore live for the assembly's lifetime and are handed to every rebuilt resolver, so source health and wanted ids survive a rebuild. Health is keyed by backend id; an addon deleted and added again gets a new account id and starts with fresh health. All three are in memory only and are lost when the process exits.
  • What is cleared. The answer cache is cleared on every rebuild, because cached answers name backends that may no longer exist. It is also cleared, without a rebuild, whenever the kept Netflix title ids change (titleIdChanges), because a cached Subscribed answer keeps the link it was built with (docs/subscribed.md, "Title links").

No-backend states #

The resolver has no backends, and answers nothing without an error, in three cases:

  • before storage is ready: ResolverModule maps every StorageState other than Ready to SourceConfiguration.EMPTY, and the StateFlow's initial value is a no-backend resolver;
  • when no addon or TMDB account has a readable secret;
  • after the configuration flow throws: the failure is caught into a no-backend resolver and the flow ends, so no further rebuild happens until the process restarts. Nothing is guessed from a partial read.

StremioIds looks up RoomStremioIds on whichever store is ready at resolve time and answers null when none is.

Change to SourceResolverImpl.status() #

status() combined every backend's status flow with the leak-guard state. combine over zero flows never emits, so a resolver with no backends — the assembly's default — had no status at all. With no backends it now emits ResolverStatus(leakGuard) from the leak-guard flow alone. SourceResolverImplTest.statusEmitsWithNoBackends pins this.

Known gaps and caveats #

  • Leak guard. The assembly holds a MutableStateFlow(LeakGuardState.UNKNOWN) that nothing updates; the connection-check probe is not wired in.
  • Backends. Only Stremio Instant and Subscribed exist. No Usenet, torrent or debrid backend is assembled.
  • Device profile. Every resolver gets DeviceProfile(), the default; no measured device profile is passed, so CandidateFilter filters against defaults.
  • Consumer. No UI injects ResolverAssembly; see Scope.
  • Credential entry. POST /api/v1/sources in :webserver tests and saves Household STREMIO_ADDON and TMDB sources through HouseholdSourceStore and SourceValidator (sources-api.md). :app implements both, RoomHouseholdSourceStore (writing this form) and HttpSourceValidator, but nothing serves the routes yet, so no running app path creates these accounts.
  • Subscriptions. The household "Streaming subscriptions" setting does not exist, so an installed app still stands in for a subscription (docs/subscribed.md).
  • Old clients. The previous resolver's backends and their OkHttp clients are not shut down on rebuild; they are left to garbage collection.
  • Cache race. A resolve still in flight on the previous resolver can write its answers into the shared cache after the rebuild cleared it, so answers naming a removed backend can briefly reappear until their cache entry expires.

M4 "Done when" checklist #

Each item of spec.md "Milestones" M4, against the tests that hold it.

Host verification #

app/src/test/kotlin/tv/streamer/app/resolver/ResolverAssemblyTest.kt runs 13 Robolectric tests over a file-backed StreamerDatabase.open(context, name) (deleted afterwards; no in-memory builder is reachable from :app), a software-key CredentialStore and MockWebServer. Every takeRequest takes a timeout.

  • No accounts: no answers, no errors, no requests.
  • One addon account: one Stremio backend whose discovery requests /addon/stream/movie/tt0133093.json with the manifest's query retained and whose probe hits the file; a second resolve is served from the cache; deleting the account rebuilds without the backend and clears the cache.
  • A TMDB account: subscribed:tmdb requesting /tmdb/movie/603/watch/providers with the key.
  • Backend order: addon, addon, subscribed.
  • Unreadable, missing and malformed credential references skip only those accounts; invalid manifest URLs skip only those addons.
  • toString of the configuration never prints a URL, host or key.
  • RoomStremioIds for a movie, a series, an episode, a missing item, a missing IMDb id and a non-IMDb value.
  • RoomStremioIds with a TMDB mapping: a missing or non-IMDb cached id is fetched from /tmdb/movie/603/external_ids (an episode from its series') and written back, a second lookup sends no request; a title with no item gets the id unstored; a TMDB error, a null imdb_id and a non-IMDb imdb_id all answer null and cache nothing, and the error is retried on the next call.
  • An addon plus a TMDB account answer a title stored without an IMDb id: the addon's stream request uses the id TMDB returned.

Not verified: any live addon or TMDB key, the Hilt provider at runtime (only compiled, through debug assembly), behaviour across a real storage reset, and anything on the device.

env ANDROID_HOME=/home/dev/android-sdk ./gradlew :app:testDebugUnitTest --tests '*ResolverAssemblyTest*' --max-workers=2 --console=plain
env ANDROID_HOME=/home/dev/android-sdk ./gradlew :resolver:test --tests '*SourceResolverImplTest*' --max-workers=2 --console=plain