# Streamer Custom TV Build — Spec Oct 1, 2026 · @Dietrich Ayala ## Goals and constraints Build a Google TV Streamer setup with our own home screen, minimal Google telemetry, and full-quality DRM streaming, without ever unlocking the bootloader. **Goals** - Replace the stock Google TV home screen with a custom launcher we build. - Show Stremio addon catalogs and installed apps (Netflix, Apple TV, etc.) on that home screen. - Install apps freely by sideloading, without the Play Store. - Cut Google and third-party telemetry as far as possible without breaking DRM. - Route traffic per app through a VPN, with streaming apps exempt. - Full navigation from a game controller as well as the stock remote. **Non-goals** - Root, custom ROMs, or any bootloader unlock. - Removing Google Play Services. - 4K and HDR. The target is 1080p; the quality profile caps there. **Hard constraints** - Widevine L1 and Play Integrity must stay intact, so Netflix and Apple TV play in full quality. - Every change must be reversible over ADB (`pm enable`, `cmd package install-existing`). ## Hardware The target device is the Google TV Streamer (4K): 4GB RAM, 32GB storage, gigabit Ethernet, no user USB port. | Item | Choice | Notes | | --- | --- | --- | | Streamer | [Google TV Streamer 4K](https://www.thainexusman.com/en/product/1540/google-tv-streamer-4k-hazel) | Thai Nexus Man, 6,390 THB, 1-year warranty; out of stock as of Sep 2026 (LINE @thainexusman). Lazada lists it too. | | Controller | Xbox Wireless Controller (Bluetooth model) | Alternative: 8BitDo Ultimate 2 Bluetooth. Bluetooth only, since the USB-C port is power. | | Remote | Stock Google remote | Kept for TV power and volume (CEC/IR). | | Network | Wired Ethernet | Preferred over Wi-Fi for streaming. | | Dev machine | Any PC with Android SDK platform-tools | For ADB over the network. | ## Device setup Set up with the existing US Google account, then enable ADB; everything after that is done over the network from the dev machine. Using a US account in Thailand means the Play Store shows the US catalog and Google TV runs its full US interface. Netflix and Apple TV take their region from their own accounts and your IP, not from Google. Thai-only apps missing from the US Play Store are sideloaded. 1. Complete setup through the Google Home app with the US account. 2. Settings → System → About → click Build 7 times to enable Developer options. 3. Developer options → enable USB debugging and network/wireless debugging. 4. From the dev machine: `adb connect :5555` and accept the prompt on the TV. 5. Sideload apps with `adb install .apk`. TV-only listings mean phone apps must be sideloaded; they may need a mouse or a remote-mouse app. 6. Install from the Play Store: Netflix, Apple TV, Tailscale, and any other DRM apps. Do not sideload DRM apps; Play Store copies keep integrity checks clean. ## Privacy hardening We reduce telemetry in three layers: settings, removing unneeded Google packages, and network blocking. Google Play Services stays, because DRM apps depend on it. **Settings** - Turn off usage and diagnostics. - Delete the advertising ID. - Turn off location. - Turn off the Assistant and voice features. **Package removal** (per user, reversible with `adb shell cmd package install-existing `) Confirm each name with `adb shell pm list packages` first; names below are from other Google TV devices. | Package | What it is | Action | | --- | --- | --- | | `com.google.android.apps.tv.launcherx` | Stock Google TV home screen | Disable after our launcher works | | `com.google.android.tungsten.setupwraith` | Fallback that re-enables the stock launcher | Disable with the launcher | | `com.google.android.katniss` | Google Assistant for TV; removing it also removes voice search | Remove | | `com.google.android.videos` | Google TV / Play Movies app | Remove | | `com.google.android.backdrop` | Ambient screensaver | Remove if unused | **Never remove:** `com.google.android.gms` (Play Services), `com.google.android.gsf`, `com.android.vending` (Play Store), or any DRM or media framework package. **Network blocking** happens through the tailnet's DNS resolver (next section). Blocking Google domains too aggressively can break sign-in and updates, so add Google domains one at a time and test. ## Networking A VPN is required on the TV, because the launcher streams torrents and Usenet itself. Android allows only one VPN at a time, so the VPN and the tailnet have to come from the same app. **Decision: Tailscale with the Mullvad exit-node add-on** - Install Tailscale from the Play Store and join the tailnet; the web UI stays reachable over it. - Buy Tailscale's [Mullvad exit-node add-on](https://tailscale.com/docs/features/exit-nodes/mullvad-exit-nodes) and set a nearby Mullvad exit, such as Singapore, on the TV. The add-on is bought through Tailscale; the existing Mullvad account is not used. - Exclude Netflix, Apple TV and other DRM apps with [app-based split tunneling](https://tailscale.com/docs/features/client/android-app-split-tunneling), so they see a normal home connection. - DNS blocking: set the tailnet's DNS to a hosted filtering resolver, such as NextDNS or AdGuard DNS. - Leak guard: see Source resolver. - Phones and laptops used to configure the TV must also run Tailscale, because credential forms are served only over the tailnet. - Keep local network access allowed while the exit node is active, so the TV stays reachable on the LAN and can reach network storage later. Verified in the M0.5 VPN spike. | Traffic | Route | | --- | --- | | Netflix, Apple TV, other DRM apps | Direct (excluded) | | Launcher: torrents, Usenet, addons, debrid, Trakt, TMDB | Mullvad exit via Tailscale | | Web UI from phone or laptop | Tailnet | | DNS | Tailnet DNS → hosted filtering resolver | The Hetzner box is not used as an exit: traffic would travel Thailand → Europe → back. ## Base decision Build from scratch in Kotlin with Compose for TV and Media3, using Lumera as a reference for patterns rather than forking it. Our core features cut across both candidate apps' data models, so a fork would mean rewriting their centre anyway. | Option | Size and activity | Stack | Already has | We would fight | | --- | --- | --- | --- | --- | | [NuvioTV](https://github.com/NuvioMedia/NuvioTV) | 5,684 commits, 2.9k stars, 660 open issues; about 222k lines of Kotlin ([per its tvOS port](https://github.com/Cb541/NuvioTVOS)) | Compose TV, Hilt, DataStore, Media3 + libmpv, libtorrent, ffmpeg | Profiles, QR phone login, account sync, advanced player and subtitles | Its own login and sync backend, profile model, large player stack, fast upstream churn | | [Lumera](https://github.com/LumeraD3v/Lumera) | 148 commits, 226 stars, 11 forks | Compose TV, Hilt, Room, Retrofit, Coil, Media3 | QR pairing and remote-paste server, addon install from phone, profiles, custom hub rows | Profile-centric model with no multi-account merge; built as an app, not a launcher; one main maintainer | | From scratch | None | Compose TV, Media3, minimal dependencies | Nothing | Nothing inherited | StreamFusionTV, listed earlier, was dropped: its repository now returns 404. **Trade-offs of building from scratch** - Playback polish is the costliest thing to rebuild: advanced subtitles, codecs, and Dolby Vision handling. Media3 covers HLS, DASH and progressive streams; edge cases can be handed to an external player app via intent. - Launcher behaviour, app rows and multi-account merging are absent from both apps, so they are new work either way. - Both apps are GPL-3.0. Copying their code would make ours GPL-3.0, which is fine for personal use; reading them for patterns has no such effect. - Scratch keeps the dependency list and network traffic fully under our control, which matters given the privacy goals. - Reference points in Lumera: `remote_input/` (QR pairing and remote paste server) and `playbackcore/` (Media3 wrapper). ## Implementation contract This section is the execution contract for a coding agent. Product behaviour elsewhere in this document wins over implementation convenience. Where a real-device M0.5 spike gates an implementation choice, implement and record the spike first; do not guess past it. A spike result is written to `docs/spikes/.md` with device build, steps, measurements, pass/fail and the selected branch. **Repository and modules** Use one Gradle Android repository with explicit boundaries. Keep domain types independent of Android where practical so merge, ranking, mapping and resolver policy run as JVM tests. ```text :app HOME activity, Compose TV UI, Android integration :core:model IDs, entities, policies and shared domain types :core:database Room schema, DAOs and migrations :core:network HTTP primitives, rate limiting and provider backoff :accounts Simkl, Trakt, Letterboxd and TMDB clients/sync :merge feed merge, audience scoping and recommendations :resolver resolver API, ranking and source orchestration :resolver:stremio Stremio manifest/catalog/stream/subtitle adapter :resolver:torrent libtorrent4j backend :resolver:usenet Newznab + embedded Usenet backend :resolver:subscribed TMDB watch-provider answers and app launch :playback Media3 player, progress, subtitles and scrobbling :webserver Ktor API, pairing, SSE and bundled SPA serving :native:usenet nzbdav-rs JNI bridge; AltMount only if spike selects it web/ source for the bundled web SPA ``` Use Kotlin coroutines and `Flow` for asynchronous/stateful boundaries. Use Hilt for dependency injection, kotlinx.serialization for domain/API serialization, OkHttp for provider HTTP, Room for relational state and DataStore for small preferences. Pin all build and dependency versions in the repository; do not use dynamic versions. The initial project bootstrap records the chosen JDK, Gradle, AGP, Kotlin, Compose, `minSdk` and `targetSdk` in `gradle/libs.versions.toml` and `docs/build-environment.md`. Choose versions compatible with the Streamer's current Android build measured in M0; after bootstrap they are part of the contract, not silently upgraded during implementation. The Android application ID is chosen once at bootstrap and recorded in `docs/build-environment.md`. Release builds support only `armeabi-v7a` for native code unless a later device target is deliberately added. Debug builds may include emulator ABIs with native engines stubbed. **Engineering rules** - No network access directly from Compose/UI code. UI calls repositories/use cases; external services live behind provider or resolver interfaces. - No provider-specific IDs are used as application identity. `MediaRef` is always the canonical TMDB-backed identity when one exists. - One provider failing never prevents another provider from refreshing or cached rows from rendering. Stale valid data beats an empty screen; errors are surfaced in Status. - Destructive sync is forbidden: a failed or partial provider response never deletes previously cached data. Replacement occurs only after a successful complete response or an explicit provider delta. - All secrets are represented by opaque credential references outside the credential store. They are never logged, serialized into normal Room rows, included in crash output, or sent to the web UI after entry. - Every externally visible state transition is idempotent where practical. Retrying a sync, write, `/open`, `want`, or source test must not create duplicate durable records. - Database migrations are explicit and tested. Release builds never use destructive migration fallback. **Core domain contracts** ```kotlin sealed interface MediaRef { data class Movie(val tmdbId: Long) : MediaRef data class Series(val tmdbId: Long) : MediaRef data class Episode(val seriesTmdbId: Long, val season: Int, val episode: Int) : MediaRef } data class ResolveRequest( val media: MediaRef, val audience: Set, val quality: QualityProfile, val forceRefresh: Boolean = false, ) interface SourceResolver { suspend fun resolve(request: ResolveRequest): ResolveResult suspend fun want(media: MediaRef, audience: Set): WantedItem fun status(): Flow suspend fun report(answerId: AnswerId, outcome: PlaybackOutcome) } ``` A `ResolvedAnswer` has a stable answer ID, `MediaRef`, answer kind, backend/source ID, parsed release metadata, verification state/time/expiry, and exactly one playback target: HTTP request (URI plus required headers), Android app target, torrent target (infohash/magnet plus optional file index), Usenet target (cached NZB reference plus selected file), or debrid job/reference. The player never reverse-engineers source-specific fields; the resolver/backend turns an answer into a playable `DataSource` or app launch. Resolver backends implement a common contract: discover candidates, verify a bounded candidate set, prepare/open the selected answer, and report health. Backend-specific transfer mechanics stay behind that boundary. **Deterministic resolver ordering** Filtering happens before ranking: reject incompatible device profile, resolution above the configured maximum, rejected release types, language violations when mandatory, expired answers and failed verification. Rank remaining answers lexicographically, not with an opaque weighted score: 1. answer-kind preference: Instant, Subscribed, Usenet, P2P, Fetch; 2. preferred resolution (1080p before 720p by default); 3. playable preferred codec/audio match; 4. original-audio match; 5. source health; 6. release quality/source class and then configured size preference; 7. source-provided rank; 8. newest verification time, then stable answer ID as the final deterministic tie-break. `Live` is outside title resolution. A user may override the default kind ordering in the quality/source settings later; until that exists the ordering above is authoritative. Up to 3 answers per kind and 10 total survive. **Persistent relationships and deletion** Room uses explicit join tables for set/many-to-many fields (`AudienceUser`, `WantedItemUser`) rather than serialized ID sets. `Item` and `Episode` are durable metadata and are not cascade-deleted when a user/account disappears. Deleting a user cascades their Accounts, ItemSources, LocalWatchlist, PlaybackProgress, subtitle preferences and audience/wanted ownership; shared Items/Episodes and source metadata remain and may later be garbage-collected if unreferenced. Deleting an Account removes its Feeds and ItemSources but not canonical Items. Household cannot be deleted. For tables that can target a title or episode, persistence uses explicit nullable `itemId` / `episodeId` columns with a database/application invariant that exactly one is set; the domain layer exposes only `MediaRef`, so callers cannot construct an ambiguous target. `Account(userId, provider, label)` does not imply uniqueness: several accounts of the same provider are allowed. Each writable capability has an optional per-user **primary write account**. If exactly one account supports a capability it becomes primary automatically; with several, the user selects one in the web UI. **Write semantics** - Scrobbling/history writes go to every tracking account of every user in the audience, as specified in UC7. - Watchlist add/remove writes to that user's primary watchlist account. If none is writable, use `LocalWatchlist`. The UI exposes the selected primary when a user has multiple writable accounts. - Explicit "mark watched" follows scrobbling semantics: every writable history account for each audience member plus local progress state. - Provider write failure is queued for retry and shown in Status; successful providers are not rolled back because another provider failed. **Continue-watching reconciliation** Local playback progress is authoritative when it has a newer `updatedAt` than imported provider progress. Otherwise use the newest provider timestamp. A position at or above 90% of duration is completed and removed from Continue watching; for episodes, the next episode becomes eligible. An explicit watched state always beats an older partial position. Conflicting provider states are retained as provenance but the newest timestamp wins the rendered state. Clock timestamps from providers are stored separately from local receipt time so reconciliation can be diagnosed. **State machines** `Transfer.state` is one of `QUEUED`, `RESOLVING`, `VERIFYING`, `BUFFERING`, `READY`, `PLAYING`, `PAUSED`, `PAUSED_VPN`, `COMPLETED`, `FAILED`, `CANCELLED`. Terminal states are `COMPLETED`, `FAILED`, `CANCELLED`; retry creates a new attempt linked to the same logical transfer. VPN failure moves any network transfer that requires the protected route to `PAUSED_VPN`; it may resume only after the leak guard passes. `WantedItem.state` is `QUEUED`, `RESOLVING`, `PREPARING`, `READY`, `FAILED`, `CANCELLED`. `READY` may reference a Transfer or a verified Instant/Subscribed answer. Auth/device flows are `PENDING`, `WAITING_FOR_USER`, `AUTHORIZED`, `EXPIRED`, `DENIED`, `FAILED`. Source tests are `RUNNING`, `PASSED`, `FAILED`. These values are shared by Room, API DTOs and SSE events; do not create parallel string states in the web UI. **Failure policy** - Provider unavailable: render cached data and mark it stale with the provider error in Status. - Token revoked/unauthorized: retain cached data, stop writes/sync for that account, and request reauthorization. - Addon unavailable: retain its last successful catalogs; playable answers must still pass current verification before being offered. - TMDB unavailable: use cached metadata; searches requiring uncached TMDB data report partial/unavailable results rather than deleting anything. - Cache full: never evict bytes required by active playback; evict completed/unpinned least-recently-used cache entries, then paused prefetch, and fail the new prefetch if space still cannot be made. - Keystore secret cannot be decrypted: mark that credential invalid and require re-entry; do not reset unrelated configuration. - Database migration failure: abort startup into a recovery screen that offers backup/export diagnostics and rollback/reinstall instructions; never destructively recreate the database. - A resolver/source failure affects only that candidate/backend; playback falls through to the next verified answer. **M0.5 decision gates** A dependent production feature is not implemented until its spike is recorded. Pass/fail means: - Launcher replacement passes only if Home/Back, reboot, wake, Settings, Inputs, Notifications, YouTube account handling, Play Store and Google account settings all work for a full reboot/wake cycle. Failure selects the Accessibility Home-button fallback and leaves the stock launcher enabled. - App-content rows pass if Netflix or Apple TV exposes stable, useful rows/programs through the permitted API on the device. Empty/unusable data drops the feature; do not emulate or scrape it. - VPN passes only if excluded DRM apps remain direct and playable, protected app traffic uses the Mullvad exit, tailnet web access works, LAN reachability works, and exit-node loss behavior is measured. Until then torrent/Usenet sockets are not production-enabled. - Torrent passes if a representative 1080p test file starts, seeks and plays stably on the real device without sustained resource pressure that disrupts playback. Record startup time, seek time, CPU and memory; a failure blocks P2P rather than prompting an unplanned engine rewrite. - Usenet passes if the selected engine builds for `armeabi-v7a`, starts and seeks within a representative multi-volume 1080p release, and remains stable under playback. If nzbdav-rs fails, run the same spike with AltMount. If both fail, Usenet becomes blocked/deferred and the rest of the build continues. - Web server passes if a paired phone can use the server throughout normal launcher use and a standby/wake cycle while the TV is on. If Android kills it outside the expected lifecycle, document the lifecycle and start it whenever the launcher becomes active; do not introduce a cloud service. A spike may block its dependent feature without blocking unrelated milestones. The agent records deviations in `docs/decisions.md`; it does not silently substitute a new architecture. ## Use cases The web UI served by the TV is the setup surface and the TV is the viewing surface: all typing happens in a browser on a phone or laptop, reached by QR or pairing code. Accounts belong to users, and the whole home screen switches between a merged view of everyone and a single user's view. **Provider capabilities** (what each service lets us do) | Provider | Sign-in | Watchlist | History | Recommendations | Write back | | --- | --- | --- | --- | --- | --- | | [Simkl](https://api.simkl.org/authentication) (primary) | PIN: the web UI shows a code, approve on simkl.com | Yes | Yes | From TMDB seeds | Yes: check-ins and history | | [Trakt](https://docs.trakt.tv/reference/auth) (optional) | Device code, approve on Trakt | Yes | Yes | Yes | Yes: scrobble, history, watchlist | | [Letterboxd](https://letterboxd.com/api-beta/) | Username, public diary RSS | From the data export (`watchlist.csv`), re-uploaded to refresh | Last 50 diary entries from RSS; full history from the export | None; derived from rated films via TMDB | No | | Stremio addon | Manifest URL | Through catalogs | None | Through catalogs | None | | TMDB | One app-level API key | None | None | Per-title recommendations, used as seeds; popular and trending lists as Home rows | None | Letterboxd's official API is by request only and explicitly excludes private, personal and recommendation projects, so it is read-only through public RSS. Its RSS covers the diary but not the watchlist ([Radarr issue](https://github.com/Radarr/Radarr/issues/3228)). **Decision: Simkl is the primary tracking provider.** Creating a Simkl API app is free with no approval, its OAuth 2.0 device flow (a code approved at simkl.com/pin) is built for TVs, and a 180-day refresh token that slides forward on each use renews the 7-day access token without asking again ([Simkl](https://api.simkl.org/api-reference/oauth2-tokens)). Trakt now requires an active VIP membership to create an API application, and free Trakt accounts can connect only one app ([tuliprox issue](https://github.com/euzu/tuliprox/issues/853), [Troypoint](https://troypoint.com/trakt-limits-users-to-one-connection/)), so Trakt is optional, for anyone who already pays for it. Letterboxd's account export includes the watchlist as `watchlist.csv` alongside diary, ratings and lists ([Letterboxd](https://x.com/letterboxd/status/1044015494066393088)). The export has Letterboxd links rather than TMDb IDs, so imported rows are matched to TMDB by title and year; unmatched rows are listed in the web UI for a manual pick. [embedded content: account merge · accounts to feeds to rows\] Each item keeps a record of which user, account and feed produced it, so switching between Everyone and a single user re-scopes rows without refetching. **UC1: First run and pairing** 1. The launcher shows a QR code and a short pairing code for its own web UI. 2. Phone: scan the QR code. Laptop: open the TV's tailnet address and type the pairing code. 3. The web UI is the only settings surface (see Configuration). The TV shows settings read-only. 4. Works over the tailnet from anywhere, and read-only on the LAN; no cloud service involved. Pairing may be initiated from either interface, but a LAN-paired browser remains read-only until it reaches the TV over its tailnet address; pairing never upgrades LAN requests to credential-bearing access. **UC2: Add a Stremio addon** 1. Paste the manifest URL in the web UI, under a user or under Sources for Household. 2. The TV fetches the manifest and lists its catalogs. 3. Tick which catalogs become home-screen rows; they appear on the TV immediately, at the end of the row list, and can be moved or turned off under Home screen. Catalogs that need a search term or another required value cannot be ticked. Details: `docs/catalog-rows.md`. **UC3: Add users** 1. Web UI → Users → Add user → name, colour, optional avatar ("Dietrich", "Partner"). 2. A built-in **Household** user always exists. It holds sources that belong to no one person, such as shared addons. 3. Users can be renamed, recoloured or deleted; deleting a user removes their accounts. **UC4: Add accounts to a user** 1. Web UI → open a user → Add account → choose the provider. 2. Simkl: the web UI shows a PIN with a link to simkl.com; the account holder approves there, and the TV stores the token encrypted. Trakt (optional) works the same way with a device code. 3. Letterboxd: enter the username. The TV reads the public diary RSS and resolves entries by TMDb ID. Optionally upload a data export for the watchlist and full history. 4. A user can hold several accounts, including two of the same provider; they merge inside that user first. 5. Watchlist writes go to the user's primary writable Simkl or Trakt account. If exactly one account can write watchlists it is primary automatically; with several writable accounts the user chooses the primary in the web UI. A user with no writable provider (for example Letterboxd only) gets a local watchlist on the TV. 6. Stremio addons can be added to a user (personal catalogs) or to Household (shared). **UC5: Everyone view** - A view switcher at the top of the home screen: **Everyone**, then one entry per user. Everyone is the default. - In Everyone, rows such as For you, Watchlist and Recently watched pull from all users. - Items are de-duplicated by canonical ID. Each tile shows colour dots for the users it came from. - Ranking (decided): items several users share come first, ordered by how many users have them. The rest are interleaved round-robin by user, each user's items in their own source order, so no single user fills a row. Recency breaks ties. The merge is deterministic: de-duplicate first by canonical ID, preserve every contributing `ItemSource`, sort shared items by descending distinct-user count then newest contributing timestamp then canonical ID, and round-robin the remaining per-user queues in stable user order. Household-only items form one additional queue in every view. - For you (decided): for each user, up to 10 seed titles: rated 4 out of 5 or higher (3.5 stars on Letterboxd), most recent first, or the last 20 watched if the user has no ratings. Each seed contributes TMDB's top 5 recommendations; Trakt recommendations are added when a user has Trakt. - Hide-watched policy, set per row: hide if anyone in the audience watched it, or only if everyone did. **UC6: One user's view** - Choosing a user in the switcher re-scopes every row to that user's accounts plus Household sources. - The row layout stays the same, so the views are easy to compare. - A long press on a tile shows why it is there: which user, account, feed, and seed film. - A row can be pinned to one user so it stays scoped while the rest of the screen shows Everyone (for example, "Partner's watchlist"). **UC7: Who's watching, and scrobbling** - The audience is a set of users, chosen with a multi-select "Who's watching" prompt and remembered until changed. - Selecting a single user in the view switcher sets the audience to that user by default. - Playback in our player scrobbles to every tracking account (Trakt or Simkl) of every user in the audience. Letterboxd cannot be written to. - The audience also drives the hide-watched policy in UC5. **UC8: Search** 1. A Search tile on the home screen opens a typed search with an on-screen keyboard. There is no voice search, because the Assistant is removed. 2. Faster: type in the web UI's search box on a phone or laptop. Results show there and on the TV. 3. Results merge TMDB and addon catalogs, de-duplicated by canonical ID. Each result shows user dots for anyone who has it in a watchlist or history, and a where-to-watch badge. 4. Any result in the web UI has "Open on TV", which makes the TV jump to its detail screen. **UC9: Detail screen** - Header: title, year, runtime, synopsis, artwork, user dots (watchlisted or watched, per user), and a Simkl credit when Simkl data is shown. - One Play button labelled with the best answer: for example "Play · 1080p", "Open in Netflix", or "Usenet · 1080p". - "More sources" lists the other verified answers grouped by kind, a few per kind, with parsed quality labels. - Series: season tabs and an episode list with per-user watched marks. Play resumes the audience's next unwatched episode from its saved position. - Actions: add to a user's watchlist, mark watched for the audience, Want (prefetch). - While a transfer prepares (torrent buffering, a debrid fetch), the button shows progress; the TV gets a notice when it is ready. ## Launcher app The launcher is a normal Android TV app registered as the HOME activity. It shows Stremio addon catalogs and installed apps as rows, and the stock launcher is disabled over ADB. [embedded content: launcher architecture · 4 parts, 4 external pieces\] Launcher traffic leaves through the Mullvad exit via Tailscale. Netflix and Apple TV are launched as separate apps and connect directly. **Becoming the home screen** - Main activity declares `MAIN` with categories `HOME`, `DEFAULT` and `LEANBACK_LAUNCHER`. - Disable the stock launcher and its fallback: `adb shell pm disable-user --user 0 com.google.android.apps.tv.launcherx` and the same for `com.google.android.tungsten.setupwraith`. Pressing Home then offers our app. Proven first in the M0.5 spike. - Fallback if an update blocks this: an Accessibility service that intercepts the Home button, as Projectivy does. - Rollback: `adb shell pm enable` on both packages. - The stock launcher also hosts system access. Our launcher provides a Settings tile that opens the system screen directly; Inputs and Notifications are not offered because an ordinary app cannot open them. - The Streamer runs a 32-bit Android userspace, so any native library must ship an `armeabi-v7a` build. **Home screen rows** (order configurable) 1. Continue watching: our player's progress, merged with the tracking provider's playback progress (Trakt or Simkl), scoped to the current view. 2. Pinned apps: Netflix, Apple TV and others, launched with `getLeanbackLaunchIntentForPackage`. 3. App content rows: the channels and Watch Next items that apps such as Netflix and Apple TV publish to Android TV. Each app's row has its own on/off toggle in the web UI. Read with \`READ\_TV\_LISTINGS\`, as Projectivy does. Only built if the M0.5 spike finds these apps publish rows on the Streamer. 4. For you, Watchlist, Recently watched: merged across users in the Everyone view, or one user's accounts in a user view. 5. One row per selected addon catalog, from Household and the users in view, plus TMDB's popular and trending movies and series as four rows of the Household TMDB key, on by default and each one turned off with a single switch. Each catalog row has its own place in the row order and is never merged with another (`docs/catalog-rows.md`). 6. More apps: installed TV and sideloaded apps that are not pinned, with hide and reorder. **Streams and catalogs** The UI never talks to addons or debrid services directly. It calls the on-device resolver (see Source resolver) for catalogs and playable answers, and plays HTTP answers with Media3 ExoPlayer. Subscribed answers launch the streaming app; edge-case formats can be handed to an external player via intent. **Input** - D-pad focus navigation throughout; the system maps gamepad A to select and B to back. - `KEYCODE_BUTTON_MODE` (Xbox guide button) returns to the home screen. - `KEYCODE_MENU` or a long press opens context actions: hide, move, app info, uninstall. **Settings and data** - All settings are edited in the web UI the TV serves (see Configuration); the TV screen only shows them. - Feeds are fetched and merged on the TV (see On-device services). Rows are stored in Room so the home screen appears instantly on wake, before the next refresh. - Credentials are encrypted with the Android Keystore. - Package visibility: declare `` for launcher intents rather than requesting `QUERY_ALL_PACKAGES`. - No analytics or crash reporting. The launcher talks only to the services you configure. ## On-device services Everything runs inside the launcher app on the TV: accounts, feeds, merging, the resolver and the web UI. Nothing is stored on the Hetzner box, and it plays no part in phase 1. | Component | Role | | --- | --- | | Account clients | Trakt or Simkl, Letterboxd RSS, TMDB; refreshed on the schedule below | | Merge engine | Builds the rows for each view | | Resolver | Turns a title into ranked, verified answers (see Source resolver) | | Web server | Serves the web UI and its API from the TV (see Configuration) | | Store | Room database and DataStore; credentials encrypted with the Android Keystore | **Resource limits on the Streamer** - 4 GB RAM, a 32-bit userspace and 32 GB of storage shared with other apps. The streaming cache for torrents and Usenet is capped at 15 GB by default and cleared after watching; the database and image cache stay small. - When network storage is available it can replace the on-TV cache, with no other change. - The TV sleeps and Android restricts background work. Account feeds sync on visible events (see Sync rules), never on a background timer; nothing assumes the TV is awake overnight. - Torrent and Usenet transfers run in one foreground service, as Android 14 requires for long-running work: type `mediaPlayback` while something plays, `dataSync` for prefetch and seeding. When the TV goes to standby, seeding stops and prefetch pauses. - The web server runs while the launcher's process is alive. If the TV is off, the web UI is unreachable; the pairing screen says so. **Security** - Over the tailnet address, web UI traffic is encrypted by WireGuard even though it is plain HTTP. - On the LAN address, only the pairing page and read-only pages are served. Forms that take credentials appear only over the tailnet address. - Pairing (QR code or pairing code) issues a device token per browser, revocable under Devices. Store browser tokens as SHA-256 hashes because the tokens themselves are uniformly random 256-bit secrets; compare hashes in constant time. - Signing in by Tailscale identity is dropped: on Android, other apps cannot ask the Tailscale app who is connecting. - Backup encryption format is versioned and self-describing. Derive a 256-bit key from the export passphrase with Argon2id (random salt; parameters recorded in the file header) and encrypt/authenticate with AES-256-GCM using a random nonce. Never reuse the Android Keystore key in an exported backup. The implementation may tune Argon2id memory/time parameters to the Streamer during M3, but records them in the backup header so restores are portable. **Data model** (on the TV) | Entity | Fields | Notes | | --- | --- | --- | | User | id, name, colour, avatar, isHousehold | Groups accounts and personal addons; one built-in Household user | | Provider | type: simkl, trakt, letterboxd, stremio-addon, newznab, usenet, torrent, debrid, tmdb, iptv | Declares capabilities: watchlist, history, ratings, recommendations, write-back, streams | | Account | id, user, provider, label, encrypted credentials | Belongs to exactly one user; any number per user and provider | | Feed | account (or user, when no account holds it), kind (watchlist, history, ratings, recommendations, catalog), fetchedAt, activitiesTimestamp | `activitiesTimestamp` gates Simkl delta syncs; a user holds a feed only for the For-you row of a person with no tracking account | | Item | canonical ID = type + TMDb ID (`tmdb:movie:603`, `tmdb:tv:1399`), IMDb, TVDB and Simkl IDs, title, year, artwork | One record per title; TVDB IDs are needed for Newznab TV search | | Episode | item, season, number, TMDb, IMDb and TVDB episode IDs | Needed for Stremio IDs, Newznab search and scrobbling | | ItemSource | item, account, feed, rank, timestamp, seed item | User is derived through the account (or is the feed's user); drives dots and "why this" | | Row | feed kind or catalog row, optional pinned user, merge and hide-watched policy | Scope = pinned user if set, else the current view; a catalog row is never pinned | | CatalogRow | source account, catalog type and id, name, shown | A ticked addon catalog or a built-in TMDB list; its titles are a catalog feed on the same account | | Audience | set of user IDs | Current "who's watching"; drives scrobbling and hiding | | PlaybackProgress | user, item or episode, position, duration, updatedAt, syncedAt | Written for every audience member; drives Continue watching and resume | | LocalWatchlist | user, item, addedAt | Used when a user has no account that accepts watchlist writes (Letterboxd only) | | ResolvedAnswer | item or episode, kind, source, release details, verifiedAt, expiresAt | Short-lived cache of resolver results | | Transfer | kind (torrent, usenet, debrid), item or episode, state, bytes, cache path, upload ratio, startedAt | Every torrent, Usenet or debrid job | | WantedItem | item or episode, users, state, transfer | Prefetch queue | | IndexerUsage | indexer, day, API calls, NZB grabs | Keeps each Newznab indexer within its daily limits | | DeviceProfile | video codecs, audio passthrough, max resolution | Measured on the TV; filters resolver answers | | SourceHealth | source, successes, failures, last failure | Ranks and drops unreliable sources | | PairedDevice | id, name, owner user, token hash, createdAt, lastSeen | Signed-in browsers; the owner (a person or Household) decides which user's accounts the browser may change | | SubtitlePreference | user, languages, size | Applied when the user is in the audience | **ID mapping** - The canonical ID is the type plus the TMDb ID, because a TMDb movie and a TMDb show can share a number. - IMDb and TVDB IDs come from TMDB's external IDs and are cached in Item and Episode. Simkl IDs come from Simkl responses. - Stremio IDs (`tt…` and `tt…:season:episode`) map through the cached IMDb IDs. Newznab TV search uses TVDB IDs with season and episode; movie search uses IMDb IDs. - Addon-specific IDs (for example Kitsu for anime) are kept as aliases when an addon's metadata supplies the mapping; unmapped items stay addon-only and are not merged. **Sync rules** Simkl suspends apps that poll without checking for changes first, without warning or appeal ([Simkl API rules](https://api.simkl.org/api-rules)). All account syncing follows Simkl's pattern. - **First sync per Simkl account:** full pull of shows, movies and anime, one after another, never in parallel. - **After that:** sync when the launcher comes to the foreground or the TV wakes, at most once every 15 minutes per account. Call `/sync/activities` first; if nothing changed, stop. Otherwise pull only changes since the saved timestamp. - **Writes** (history, watchlist, ratings) are batched into one call. - **Limits:** at most 10 GET and 1 POST per second, per app and per user token. Every request carries the app's User-Agent; failures back off up to 60 seconds and give up after 5 tries. - **Attribution:** the detail screen credits Simkl for Simkl data, and the web UI links each item to its Simkl page. - **Trakt (optional):** the same event-driven pattern. | Source | When it refreshes | | --- | --- | | Simkl and Trakt | On launcher open or wake, at most every 15 minutes, gated by activities | | Letterboxd RSS | On launcher open, at most hourly | | TMDB metadata and recommendations | Cached for 7 days | | TMDB where-to-watch | Cached for a day | | Addon catalogs and TMDB popular and trending rows | On launcher open, at most hourly per row; a failed refresh keeps the row's last titles | All external calls go through one rate-limited client per provider with backoff. **Region** Every TMDB lookup (where-to-watch, and any region-dependent list such as popular movies) uses the Household region, a country chosen under Home screen on the phone page. It starts as US, matching the US accounts the Streamer and its streaming apps use. Any tailnet browser may change it; the change applies to the next lookup without a restart, and the popular-movies row refreshes for the new region on the next Home open. **Backup** The TV is the only copy of the configuration. The web UI exports a backup file encrypted with a passphrase you enter at export time; restoring it after a reset or reinstall asks for the passphrase, then re-encrypts secrets with the TV's own Keystore key. **Hetzner box (later, optional)** Used only if a need appears, and kept stateless: an indexer service such as Prowlarr for direct indexer search, an always-on refresher so feeds are fresh when the TV wakes, or a target for automatic config backups. ## Source resolver All stream sourcing lives in one resolver inside the launcher app. For each title it returns a short, verified, ranked answer, never a raw list of every candidate. The TV streams torrents and Usenet itself, through the required VPN; debrid is optional. **Decision: on-device first, no single required provider.** The resolver is a module behind a Kotlin interface (`resolve`, `want`, `status`, `report`). Each way of getting bytes is a pluggable backend behind that interface, so any one of them can fail or be removed without breaking the others, and parts can move to the Hetzner box later. **Answer kinds**, tried in this order: | Kind | Meaning | Play behaviour | | --- | --- | --- | | Instant | Playable now over HTTP: a direct URL from an addon, or a file cached on a debrid service (optional) | Plays immediately | | Subscribed | A service you subscribe to offers it in your region (TMDB where-to-watch, data from JustWatch) | Opens the app. Opens the specific title only when its ID is known (for Netflix, `https://www.netflix.com/title/?source=30`); otherwise the app's home | | Usenet | A release from a Newznab indexer, streamed by the TV's Usenet engine from your Usenet provider | Starts once the archive headers are read; seekable; nothing is fully downloaded first | | P2P | A torrent from an addon or indexer, streamed by the TV's torrent engine through the VPN | Starts after a short buffer; pieces are fetched in playback order | | Fetch | Optional debrid cloud download, for titles no other kind can play | Added to the wanted list; notice when ready | | Live | IPTV channel | Separate Live surface with a guide, not a title | **Engines on the TV** - Torrent engine: libtorrent through libtorrent4j, which ships Android builds (NuvioTV bundles it). Pieces are fetched in playback order. Data goes to a rolling cache on the TV, capped at 15 GB by default and cleared after watching. Seeding follows a ratio and time limit set in the web UI, and only while the leak guard passes. - Usenet engine: streams inside a release without downloading it first: NNTP connections to your provider, yEnc decoding, and reads inside RAR volumes on demand. Releases it cannot stream fall back to downloading into the same capped cache. - Usenet engine decision: embed the Rust crates from [nzbdav-rs](https://github.com/AusAgentSmith-org/nzbdav-rs) (MIT licence) through JNI, built for 32-bit ARM with cargo-ndk. It is already split into library crates: an async NNTP client, yEnc decoding, PAR2, a header-only RAR4/RAR5 parser, and on-demand seekable streams. It is a small project, so [AltMount](https://github.com/kipsilabs/altmount) (MIT, Go, larger community) is the fallback, embedded with gomobile. - Player connection: each engine is exposed to Media3 as an in-process `DataSource` that serves range reads and blocks until the bytes are available. No local HTTP servers. - Both engines run inside the transfers foreground service (see On-device services). **Leak guard** - Checks that traffic exits through Mullvad (Mullvad's connection-check service) when a transfer starts, every 60 seconds while any transfer is active, and on every network change reported by Android. - On failure it pauses all transfers at once and the player shows "VPN check failed" instead of stalling. Transfers resume automatically when the check passes. - The M0.5 VPN spike establishes what Tailscale does with our traffic if the Mullvad exit is cleared or unreachable; the guard is designed for the worst case, that traffic goes out directly. TMDB's where-to-watch data carries no deep links and requires JustWatch attribution wherever it is shown ([TMDB docs](https://developer.themoviedb.org/reference/movie-watch-providers)). **Only working options are shown** - Every HTTP candidate is probed with a range request before it is shown. Torrent candidates are checked by seeder count and a metadata fetch. - Usenet checks have a budget, because each one costs an indexer API call and an NZB grab. The resolver ranks Usenet results by their parsed release details first, fetches NZBs only for the top 3, and confirms a sample of articles (start, middle and end of the main file) with the provider. NZBs are cached for 24 hours. Each indexer's daily API and grab limits are set in the web UI and tracked in IndexerUsage. - A release-name parser turns raw names from addons and indexers into resolution, source (WEB-DL, Blu-ray and so on), video codec, audio, language, size and group. Camera and telesync releases are rejected. - Season packs: the engine picks the episode file by the stream's file index when the addon provides one, otherwise by matching `SxxEyy` in the pack's file list. - Debrid, when configured, is per provider. Real-Debrid and AllDebrid no longer expose cache checks, so the resolver records what was cached before. - The TV measures its own device profile (video codecs, audio passthrough). Candidates it cannot play, or above 1080p, are dropped before ranking. - Each source keeps a health score from past successes and failures; low scorers rank down or drop out. - The detail screen shows one Play button for the best answer. Alternatives sit behind "More sources". - If playback fails, the player silently falls back to the next answer and records the failure. **Quality profile defaults** (editable in the web UI) | Setting | Default | | --- | --- | | Resolution | 1080p preferred; 720p only when no 1080p answer exists | | Video codec | H.264 or HEVC, whichever the device profile plays; AV1 only if the device decodes it | | Audio | AAC, AC-3 or E-AC-3; DTS only if the TV passes it through | | Language | Original audio; subtitles in each audience member's subtitle languages | | Size | Movies 1–12 GB, episodes 0.3–4 GB | | Rejected | Camera, telesync and screener releases | | Kept | Up to 3 answers per kind, 10 in total, after ranking | **Prefetch** - Merged watchlists and the next unwatched episodes from Continue watching feed the wanted list. - For wanted items the resolver pre-resolves answers and pre-reads the first minutes into the cache, so playback starts quickly. Pre-reading a torrent also uploads to other peers, so torrent prefetch is limited to next episodes; Usenet prefetch has no such limit. With debrid configured, uncached items can also be sent there to fetch. - Prefetch runs only while the TV is on, and the cache stays within its cap. **Web API** (served by the TV, used by the web UI; `/api/v1`; device token required except pairing and `/signin-link`) | Call | Purpose | | --- | --- | | `POST /pair` (QR token, pairing code or sign-in link) | Issues a device token for a browser; accepts exactly one of `qrToken`, `pairingCode` or `signinToken` | | `GET`, `DELETE /devices` | Signed-in browsers; revoke | | `GET`, `PUT /devices/me/owner` | Who this browser acts for; a switch that needs approval returns 202 and completes when OK is pressed on the TV | | `GET`, `PUT /settings` | Home screen: row order, catalog rows on or off, pinned apps, toggles, default view, region | | `GET`, `PUT /quality` | Quality profile | | `GET`, `POST /users`; `PATCH`, `DELETE /users/{id}` | People: list, add, edit name, colour, avatar and sign-in email address, delete; editing is for the person's own browser, a change of email address waits for OK on the TV, and a delete from any browser waits for OK on the TV | | `GET`, `POST`, `DELETE /users/{id}/accounts` | Planned generic provider accounts API | | `GET /users/{id}/trakt`, `POST /users/{id}/trakt/signin`, `POST /users/{id}/trakt/signin/poll`, `DELETE /users/{id}/trakt/signin`, `DELETE /users/{id}/trakt/accounts/{accountId}`, `POST /users/{id}/trakt/accounts/{accountId}/revoke` | Implemented owner-scoped Trakt device sign-in, local disconnect, explicit remote revocation and recommendations; own application credentials, automatic serialized encrypted refresh/rotation (`docs/trakt-signin.md`) | | `POST /users/{id}/letterboxd-import` | Upload a Letterboxd export; returns matched and unmatched rows | | `GET`, `POST`, `DELETE /sources`; `POST /sources/{id}/test` | Household sources and the Test button | | `GET`, `PUT /sources/{id}/catalogs` | An addon's catalogs (or the TMDB lists) and which of them are Home rows | | `GET`, `PUT`, `DELETE /mail-account`; `POST /mail-account/test` | The outgoing mail account under Sources and its Test button | | `GET`, `POST /signin-link` | Whether sign-in links can be sent; ask for one by email address | | `GET /search?q=…` | Titles to add from the phone: TMDB movies and series through the Household key and region, merged into one list (`docs/title-search.md`); addon catalogs are not searched yet; any paired browser, LAN included | | `GET /search/link?url=…` | Titles to add from a pasted Netflix or Apple TV share link: the TV fetches the link's page, reads its title and searches TMDB as above, and returns a Netflix link's title id with the results; any other address is 400 without a fetch; any paired browser, LAN included | | `POST /users/{id}/watchlist` (type, TMDB id, optional Netflix id) | Adds a found title to the person's local watchlist; only that person's browser, over the tailnet; adding a title twice changes nothing. A Netflix id (digits, from the Netflix link the title was found by) is kept against the title so its "Open in Netflix" opens the title (`docs/subscribed.md`, "Title links") | | `POST /open` (title, episode) | Makes the TV jump to that detail screen | | `GET /status` | Wanted list, transfers, indexer usage, source health, leak-guard state | | `POST /backup` (passphrase), `POST /restore` (file, passphrase) | Encrypted configuration export and import | | `GET /events` | Server-sent events: `auth`, `transfer`, `wanted`, `source-health`, `leak-guard`, `sync`, `open`; each event has an ID, type, timestamp and typed payload; support `Last-Event-ID` for events retained in the in-memory replay buffer | All API errors use a JSON body `{ "error": { "code": "STABLE_CODE", "message": "human-readable", "details": {...} } }`. Validation errors are 400, missing/invalid device tokens 401, authenticated-but-disallowed LAN mutations 403, missing resources 404, conflicts/state-transition errors 409, provider rate limits 429 where meaningful, and unexpected failures 500. Mutating endpoints require `Content-Type: application/json` except uploads/restore. Cookie authentication plus `SameSite=Strict` is supplemented by rejecting mutating requests whose `Origin`/`Host` is not the TV web origin; the API does not enable permissive CORS. `/restore` is multipart with the encrypted backup file plus passphrase and restores transactionally: validate/decrypt/migrate first, then replace configuration in one database transaction. `/open` is idempotent for the same media target and brings the launcher/detail activity forward when Android permits it. Stremio addons become adapters inside the resolver: added in the web UI and normalised. Torrent hashes they return now play on the TV through the torrent engine, with no debrid needed. Addons can also supply subtitles; the player shows subtitles from every configured subtitle addon. Default addons (decided): Stremio's official OpenSubtitles addon for subtitles, and nothing else. Metadata and catalogs come from TMDB built in; every stream addon is one you add yourself. **Source to-do list** (implement in this order) - [ ] Instant: direct HTTP URLs from addons - [ ] Stremio addon adapter: existing addons as sources and catalogs - [ ] P2P: torrent engine on the TV, streaming addon torrent results - [ ] Usenet: Newznab indexer search plus the TV's Usenet streaming engine - [ ] Subscribed: TMDB where-to-watch for the Household region (US by default), open the app (title-level links only where an ID is known), JustWatch attribution - [ ] Debrid (optional): Instant and Fetch through one or more debrid services, with failover between them - [ ] Live: IPTV via M3U playlists and XMLTV guides **Indexing** - Phase 1 runs no indexer of its own. Torrent results come from Stremio addons, which do the indexing. Usenet results come from Newznab-compatible indexers (paid API subscriptions) searched directly by the TV, the same pattern the [Usenet Ultimate](https://github.com/DSmart33/Usenet-Ultimate) addon uses. - Own torrent index, later: [Bitmagnet](https://bitmagnet.io/) crawls the DHT network itself, so it needs no outside tracker or indexer. It needs [around 300 MB RAM plus at least 1 GB for Postgres](https://bitmagnet.io/faq.html) and continuous uptime, so it would run on the Hetzner box behind a VPN, never on the TV. Its database holds metadata only. - Own Usenet index: possible with NNTmux, but it bundles MariaDB, Redis and Manticore and scrapes headers continuously, and many posts are obfuscated, so a self-run index finds far less than a paid indexer. Not planned. Point every source at content you're entitled to: your library, your subscriptions, paid services. ## Configuration (web UI) Every setting, account and credential is configured in one web UI served by the TV itself, from a phone or a laptop. Secrets stay on the TV, encrypted with the Android Keystore. **Getting in** 1. The TV shows a QR code and a 6-digit pairing code. Both point at the TV's web UI at its tailnet address. 2. Phone: scan the QR code. The QR token is 128 random bits, single use, valid for 10 minutes. Pairing is allowed from LAN or tailnet, but the resulting browser is authorized for mutations only when its requests arrive over the tailnet address. 3. Laptop: open the TV's tailnet address and type the pairing code. The code is valid for 5 minutes; after 5 wrong attempts a new code is shown. 4. A successful pairing sets a device token (256 random bits) in an HttpOnly, SameSite=Strict cookie. The TV stores only a hash of it. 5. The server tells tailnet requests from LAN requests by the local address the request arrived on (tailnet addresses are in 100.64.0.0/10). Credential forms are served only to tailnet requests; LAN requests get read-only pages. 6. Each signed-in browser appears under Devices and can be revoked. **Whose browser this is** A phone is personal: its owner is usually already signed in to their tracking services there, so the phone is where that person connects their own accounts, and the result reaches the TV with nothing typed on the TV. - Every paired browser has an owner: one person, or Household for a shared laptop. Right after pairing, the page asks who is using it, listing the people on the TV and offering to add a new one. The owner is shown in the page header and under Devices. - A browser changes only its owner's things: that person's name, colour and avatar, accounts, personal addons, primary write accounts and rows pinned to them. Another person's accounts and personal addons are shown read-only, with labels and status but never credentials. A request that would change them is refused with 403 `NOT_OWNER`. - Household things stay open to every tailnet browser: Sources, Quality, Home screen, adding a person, and revoking a browser. - Switching the browser's owner is a picker in the page header. Switching to Household or to a person no browser owns yet happens at once. Switching to a person who owns another paired browser shows a confirmation on the TV ("Let act as ?") that a press of OK on the remote approves. Deleting a person and restoring a backup need the same TV confirmation, because each one removes another person's accounts and browsers; any tailnet browser may ask to delete a person (never Household), so a guest who left or a person whose phone was revoked can still be removed, and the delete always waits for the TV. A browser changes only its own owner: no call changes the owner of another browser, so a Household-owned browser becomes personal only by its own picker. - Deleting a person revokes the browsers they own, so a former member's phone never becomes a shared one. - People can be short-lived: a guest or party member adds themselves after pairing like anyone else, and is deleted the same way. Until the network access below is replaced, a guest on the home Wi-Fi only can view but not add themselves or titles; Tailscale is an interim transport, and its replacement decides what a Wi-Fi-only browser may change. - Optional email per person, for getting back in: the page offers a "send me a sign-in link" field. The link carries a single-use token (256 random bits, stored as a hash, valid 7 days) that pairs the browser opening it as that person, without the TV confirmation, because only that person's inbox received it. For the same reason, setting, changing or clearing a person's address takes effect only after OK on the TV, and the page's answer does not say whether another person already has that address. The link points at the TV's tailnet address, so it works only where the TV is reachable. Sending needs an outgoing mail account entered under Sources (SMTP host, port, username, password, From address); no mail service is built in, and with none configured the field is hidden. The address is stored only on the TV and is used for nothing else. - Adding a title by pasting a share link copied from the Netflix or Apple TV app into the page's search box. The TV, not the browser, fetches the link's page (a browser cannot read another site's page), reads the title from it and searches TMDB for that title; the person picks the matching result, and it goes to the pasting browser's owner's local watchlist like a typed search, so the title keeps who added it and when, like every other `ItemSource`. Only Netflix and Apple TV title links are fetched; anything else is refused before the TV fetches anything (`docs/title-search.md`, "Share links"). - Connecting an account from the phone: for Simkl PIN and Trakt device-code sign-in, the TV requests the code and the phone page opens the provider's approval page with the code already filled in, where the phone is normally signed in already. One tap on the provider's page approves it, the TV's poll receives the token, and the token is stored only on the TV; the browser never holds it. *Unverified:* that simkl.com and trakt.tv accept the code in the approval link; otherwise the page shows the code to paste. Redirect-based sign-in is not used, because providers require a registered redirect address and the TV's tailnet address is private to each household. Letterboxd connects with a username and an export upload from the phone's file picker; a personal Stremio addon is a pasted manifest URL. **Web UI stack:** a Ktor server (CIO engine) inside the launcher app serves a small single-page app bundled in the APK's assets, a JSON API, and server-sent events for live updates. **Web UI sections** | Section | What is configured there | | --- | --- | | Status | Wanted list, transfers, indexer usage, source health, failed plays, leak-guard state | | Users | Per user: name, colour, avatar, accounts (Trakt or Simkl, Letterboxd), personal addons | | Sources | Household services, in the resolver's to-do order, each with a Test button | | Quality | The quality profile applied to every candidate | | Home screen | Row order, catalog rows on or off, pinned apps, app content row toggles, default view, region | | Devices | Signed-in browsers; revoke | | Backup | Export and import the encrypted configuration | **Where each credential lives** | Credential | Scope | Section | How it is entered | | --- | --- | --- | --- | | Trakt or Simkl | Per user | Users → person → Accounts | Simkl: PIN shown in the web UI, approved on simkl.com. Trakt (optional): device code, approved on Trakt | | Letterboxd | Per user | Users → person → Accounts | Username; optional data-export upload | | Stremio addon | Per user or Household | Users → person, or Sources | Manifest URL | | Usenet provider | Household | Sources | Host, port, SSL, username, password, connections | | Newznab indexers | Household | Sources | API URL + API key per indexer | | Torrent engine | Household | Sources | Cache cap, seeding ratio and time limits | | Debrid services (optional) | Household | Sources | API key; order for failover | | TMDB | Household | Sources | API key | | Streaming subscriptions | Household | Sources | Tick which services you have, for Subscribed answers | | IPTV | Household | Sources | M3U URL + XMLTV guide URL | **Rules** - Every credential form has a Test button. The TV validates it (a debrid key works, an addon manifest loads) before it can be saved. - Any Household source can be moved to a single user, the same way addons can. - Secrets never leave the TV except inside an encrypted backup file you export. - The web UI works only while the TV is on. ## Build, test and release The app is built in CI for the Streamer's 32-bit ARM userspace and installed over ADB; anything that touches native code or the network engines is tested on the real device. **Build** - Kotlin, Compose for TV, Media3, Room, DataStore, Ktor server; the web UI's single-page app is built separately and bundled into the APK's assets. - Native code: libtorrent4j for `armeabi-v7a`, and the nzbdav-rs crates built with cargo-ndk for `armv7-linux-androideabi`. - CI (for example GitHub Actions) produces a signed release APK on every tagged version. You hold the signing key. **Test** | Layer | How it is tested | | --- | --- | | Resolver ranking, release-name parser, merge, ID mapping | JVM unit tests with recorded fixtures | | Simkl, Trakt, TMDB, Letterboxd, addon and Newznab clients | Recorded responses replayed through a fake HTTP server, including rate-limit and error responses | | Usenet engine | A fake NNTP server in the test suite serving a small test release split into RAR volumes | | Torrent engine | A local test swarm with a small test file | | UI and focus navigation | Android TV emulator (x86), which cannot load the 32-bit ARM native libraries; engines are stubbed there | | Engines, VPN, leak guard, launcher replacement | On the Streamer, scripted over ADB | **Release and update** - Install and update with `adb install -r` from the dev machine. - Later: the app checks the GitHub Releases of its own repository and offers updates, verifying the signature before installing. ## Milestones Each milestone leaves the device usable, so we can stop at any point. A milestone is complete only when its acceptance criteria pass; code existing in the tree is not enough. Hardware-dependent criteria run on the Streamer. Every milestone adds/updates automated tests for its deterministic logic and records any spec deviation in `docs/decisions.md`. - [x] **M0 Device ready:** Streamer set up on the US account, ADB working, Netflix and Apple TV confirmed in full quality. Record `adb shell pm list packages` to confirm the package names. - **Done when:** the device build/firmware and package inventory are saved under `docs/device/`; ADB reconnects after reboot; Netflix and Apple TV playback are manually confirmed; rollback commands for every package change are generated before any package is disabled. - [ ] **M0.5 Spikes** (throwaway tests on the real Streamer; each one decides a design point and writes `docs/spikes/.md`): - Launcher replacement: install a minimal HOME app, disable `launcherx` and `setupwraith`, reboot. Confirm Home, Back, wake from standby, Settings access, input switching and notifications. Also confirm YouTube, the Play Store and Google account settings still work: on at least one Google TV device, disabling `launcherx` broke YouTube's account handling ([report](https://enemyhideout.com/2025/09/hacking-your-android-tv/)). Repeat after the next OTA update. - App content rows: with `READ_TV_LISTINGS`, list the channels and Watch Next programs Netflix and Apple TV publish on this device. Empty means the feature is dropped. - VPN: Tailscale with a Mullvad exit and DRM apps excluded; confirm Netflix and Apple TV still play, the web UI is reachable over the tailnet, the TV stays reachable on the LAN with the exit node active, the leak check works, and what happens to the app's traffic when the exit node is cleared or unreachable. - Torrent engine: libtorrent4j on the Streamer, streaming a 1080p file through the VPN into Media3; measure start time, seek time, CPU and memory. - Usenet engine: build the nzbdav-rs crates for 32-bit ARM, stream a 1080p release into Media3 without full download; measure start time, seeking, CPU and memory. If it fails, repeat with AltMount before marking Usenet blocked/deferred. - Web server: a minimal embedded server in the HOME app, reached from a phone over the tailnet; check it stays usable through normal launcher use and standby/wake while the TV is on. - **Done when:** every spike has a recorded result and selected implementation branch according to the M0.5 decision gates. Failed optional/dependent features are explicitly marked blocked/deferred rather than replaced ad hoc. - [ ] **M1 Network:** Tailscale with the Mullvad exit add-on, DRM apps excluded, tailnet DNS pointed at a hosted filtering resolver, leak guard. - **Done when:** protected test traffic is observed through Mullvad; excluded DRM apps use the direct route; tailnet and LAN reachability match the spec; loss/clearing of the exit node moves protected transfers to `PAUSED_VPN` before bytes continue flowing; recovery resumes them. - [ ] **M2 Launcher skeleton:** HOME activity with pinned apps, all-apps row, Settings and Inputs tiles; app content rows only if the spike found data. Installed beside the stock launcher. - **Done when:** remote and gamepad can traverse every focus target; apps launch; Settings/Inputs work; Home/Back/wake work; hidden/reordered apps persist; no stock package must yet be disabled for normal testing. - [ ] **M3 Web UI on the TV:** Ktor server with the bundled single-page app, pairing (QR code, pairing code), device tokens, Devices, Home screen and Backup sections. - **Done when:** QR and six-digit pairing expire/reject attempts as specified; token revocation immediately blocks that browser; LAN mutations return 403 while read-only pages work; tailnet mutations work; backup round-trips into a clean test database; wrong passphrase/tampered backup fails without modifying state; SSE reconnect/replay works. - [ ] **M4 Resolver interface:** `resolve`, `want`, `status`, `report` with stubbed answers; ID mapping; Sources section with Test buttons. - **Done when:** contract tests cover every answer target and state transition; resolver ordering fixtures produce exact expected order; invalid/incompatible answers are filtered before ranking; source tests cannot save invalid credentials; IMDb/TVDB/Stremio mappings round-trip through recorded fixtures. - [ ] **M5 Users and accounts:** users and Household, Simkl PIN sign-in (Trakt optional), Letterboxd RSS and export import, multiple accounts per user, event-driven sync following Simkl's rules, local watchlist. - **Done when:** two accounts of one provider can coexist; primary writable account semantics work; deleting a user/account obeys the cascade contract without deleting shared canonical metadata; Simkl first/delta sync and rate limits pass replay tests; one failed provider leaves other/cached feeds intact; Letterboxd unmatched imports are recoverable manually. - [ ] **M6 Merge:** Everyone and per-user views, view switcher, de-duplication and ranking, user dots, pinned-user rows, "why this", TMDB seed recommendations. - **Done when:** fixed A/B/C/Household fixtures produce exact expected row order; duplicate canonical IDs render once with every contributing dot/source; switching views performs no provider fetch; Household appears in each applicable view; pinned-user rows are invariant across global view changes; "why this" exposes all contributing `ItemSource`s and recommendation seeds. - [ ] **M7 Search, detail and playback:** search (UC8), detail screen (UC9), one Play button with "More sources", Media3 player, subtitles, silent fallback, continue watching, audience selector, scrobbling. - **Done when:** web search can open the same detail screen on TV; playback failure advances to the next verified answer and records source health; audience changes affect progress/scrobbling exactly as specified; Continue watching reconciliation fixtures cover local/provider conflicts, 90% completion and next-episode behavior; controller-only operation can search, inspect, play, seek and return Home. - [ ] **M8 Sources:** work through the resolver's source to-do list in order; release-name parser, quality profile, Usenet check budget, transfers foreground service, leak guard, prefetch and the capped streaming cache. - **Done per backend:** its M0.5 prerequisite passed; recorded fixtures/integration tests cover discovery, bounded verification, playback/prepare, failure and health reporting; it obeys quality filtering and leak guard. The milestone is complete when every non-optional, non-blocked source in the source to-do list meets that contract. Cache tests prove active playback is never evicted and the 15 GB cap is enforced. - [ ] **M9 Default launcher:** make the spike result permanent: disable scripts, rollback tested, re-apply check after updates. - **Done when:** a clean script can apply the launcher configuration, a separate script restores stock behavior, both survive a reboot test, and the script refuses to disable unrecognized/unconfirmed package names. - [ ] **M10 Hardening and controller polish:** package removals, guide-button home, context menus, focus edge cases. - **Done when:** every removal has a tested rollback; Netflix/Apple TV/YouTube/Play Store/account settings still pass the M0.5 smoke suite; no known screen traps D-pad/gamepad focus; no secrets appear in logs/backups without encryption; release APK installs over the previous release without data loss. ## Risks and open questions The main risk is Google updates undoing our changes; every change is scripted so it can be re-applied. **Risks** | Risk | Mitigation | | --- | --- | | Google TV updates make launcher replacement harder ([reported spring 2026](https://android.gadgethacks.com/news/google-tv-update-blocks-third-party-launcher-workaround/)) | Spike in M0.5; re-test after every OTA; fallback is an Accessibility-based Home-button override, as Projectivy uses | | Netflix and Apple TV publish no readable rows on Google TV; Watch Next loses support in the second half of 2027 ([Google](https://android-developers.googleblog.com/2026/05/increase-google-tv-app-discovery.html)) | Spike in M0.5; app content rows are optional; pinned apps always work | | Simkl suspends the app's API key for polling without change checks ([Simkl API rules](https://api.simkl.org/api-rules)) | Event-driven sync gated by `/sync/activities`, rate limits enforced in the client, covered by replay tests | | Trakt API apps now require VIP; free accounts get one connected app ([tuliprox issue](https://github.com/euzu/tuliprox/issues/853), [Troypoint](https://troypoint.com/trakt-limits-users-to-one-connection/)) | Decided: Simkl is the primary tracking provider; Trakt is optional | | The VPN drops or the exit node is cleared, and torrent or Usenet traffic leaves unprotected; torrents also upload | Leak guard checks at start, every 60 seconds and on network changes, and pauses transfers on failure; seeding limits set in the web UI | | Newznab indexers' daily API and grab limits run out | Usenet check budget (top 3 results), NZB cache, per-indexer counters | | The Usenet streaming engine is the hardest new component (NNTP, yEnc, archives, seeking) on a 32-bit TV | Spike in M0.5; nzbdav-rs crates embedded, AltMount as fallback; fallback downloads into the capped cache | | Android kills long-running transfers in the background | Transfers foreground service with declared types; stops on standby | | 32 GB of TV storage fills up | Streaming cache capped at 15 GB and cleared after watching; network storage later | | Debrid services change or purge caches ([Ferrite](https://github.com/Ferrite-iOS/Ferrite/releases/tag/v0.7.3)) | Debrid is optional, with failover between services; torrent and Usenet engines work without it | | A system update re-enables the stock launcher or removed packages | Keep a re-apply script; check after each update | | Disabling the wrong package breaks setup, Home, YouTube or DRM | Disable one package at a time; rollback commands ready; spike checks YouTube and account settings | | Netflix or Apple TV block the VPN | They are excluded from Tailscale and connect directly | | Letterboxd changes or removes public RSS | Letterboxd is an optional provider | | Over-blocking Google domains breaks sign-in or updates | Add Google blocks one at a time and test | **Open questions** - [ ] Exact package names on the Streamer's current firmware: answered on the device in M0. - [ ] Network storage: where it lives and how the TV reaches it. Design note: the app would read and write it through an SMB or WebDAV client library, since Android TV cannot mount shares system-wide, and Tailscale's exit-node setting must allow local network access so the NAS stays reachable. **Sources** - [Thai Nexus Man: Google TV Streamer 4K](https://www.thainexusman.com/en/product/1540/google-tv-streamer-4k-hazel) - [FLauncher: disabling the default launcher](https://github.com/svrooij/flauncher) - [XDA: replacing the Google TV launcher (June 2026)](https://www.xda-developers.com/google-tv-bloated-replaced-custom-launcher-actually-works/) - [Gadget Hacks: Google TV update blocks launcher workarounds](https://android.gadgethacks.com/news/google-tv-update-blocks-third-party-launcher-workaround/) - [Projectivy Launcher XDA thread (permissions)](https://xdaforums.com/t/app-android-tv-projectivy-launcher.4436549/) - [Android Developers Blog: Engage SDK and Watch Next support ending](https://android-developers.googleblog.com/2026/05/increase-google-tv-app-discovery.html) - [Android Developers: Watch Next on Google TV requires certification](https://developer.android.com/training/tv/discovery/watch-next-add-programs) - [Tom's Guide: Streamer runs 32-bit Android 14](https://tomsguide.com/entertainment/streaming-devices/google-tv-streamer-will-be-the-first-device-to-feature-android-14-for-tv) - [Tailscale: app-based split tunneling on Android](https://tailscale.com/docs/features/client/android-app-split-tunneling) - [Tailscale: Mullvad exit nodes](https://tailscale.com/docs/features/exit-nodes/mullvad-exit-nodes) - [NuvioTV](https://github.com/NuvioMedia/NuvioTV) - [NuvioTVOS (size and architecture of NuvioTV)](https://github.com/Cb541/NuvioTVOS) - [Lumera](https://github.com/LumeraD3v/Lumera) - [Trakt: authentication and device flow](https://docs.trakt.tv/reference/auth) - [tuliprox issue: Trakt API apps require VIP](https://github.com/euzu/tuliprox/issues/853) - [Troypoint: Trakt free accounts limited to one app](https://troypoint.com/trakt-limits-users-to-one-connection/) - [Letterboxd: API access policy](https://letterboxd.com/api-beta/) - [Radarr issue: Letterboxd RSS covers diary, not watchlist](https://github.com/Radarr/Radarr/issues/3228) - [TMDB: watch providers (no deep links, JustWatch attribution)](https://developer.themoviedb.org/reference/movie-watch-providers) - [Home Assistant: Netflix title links need source=30](https://github.com/home-assistant/home-assistant.io/pull/48516) - [Ferrite: Real-Debrid and AllDebrid cache checks removed](https://github.com/Ferrite-iOS/Ferrite/releases/tag/v0.7.3)