# TMDB API Key Resolution Chain

## Files in Play

| File | Role |
|------|------|
| `local.properties` | Gitignored — holds `TMDB_API_KEY=***` and `TMDB_API_KEY_FALLBACK=***` |
| `app/build.gradle.kts` | Reads `local.properties` → `BuildConfig.TMDB_API_KEY` and `BuildConfig.TMDB_API_KEY_FALLBACK` |
| `ApiSecretsProvider.kt` | `resolve(userValue, buildValue)` — user DataStore wins, BuildConfig is fallback |
| `TmdbApiKeyHolder.kt` | Thread-safe volatile cache with fallback rotation on 401/403 |
| `TmdbApiKeyProvider.kt` | Thin facade — `apiKey` getter + `resolve()` suspend + `hasKey()` |
| `TmdbSettingsViewModel.kt` | Settings UI — save/clear user override, masked display |
| `PreferencesManager.kt` | DataStore persistence for user's `tmdbApiKey` |

## Full Resolution Chain

```
Settings UI (user enters key)
  → TmdbSettingsViewModel.saveKey()
    → PreferencesManager.setTmdbApiKey(trimmed)
      → EncryptedSharedPreferences / DataStore
        ↓
ApiSecretsProvider.tmdbApiKey()
  → resolve(
      preferencesManager.tmdbApiKey.first(),   // user's key (or empty)
      BuildConfig.TMDB_API_KEY                 // from local.properties
    )
  → user key if non-blank, else BuildConfig key
    ↓
TmdbApiKeyHolder.peek() / refresh()
  → @Volatile cachedKey (thread-safe reads without suspend)
  → refresh(): if useFallback, return fallbackKey; else return secrets.tmdbApiKey()
  → invalidate(): cycles useFallback flag, swaps cachedKey
    ↓
TmdbApiKeyProvider
  → apiKey (sync getter): keyHolder.peek().ifBlank { BuildConfig.TMDB_API_KEY }
  → resolve() (suspend): keyHolder.refresh()
  → hasKey() (suspend): resolve().isNotBlank()
    ↓
Consumers:
  - TmdbCatalogRepository.getCatalogRows() → every API call uses resolve()
  - TmdbMetadataService → movie/TV details, search, similar, cast/crew
  - TrailerService → YouTube trailer search via TMDB videos endpoint
  - DetailsViewModel → metadata enrichment, watch provider info
```

## Priority Order

1. **User's key** from Settings → DataStore (`PreferencesManager.tmdbApiKey`)
2. **`BuildConfig.TMDB_API_KEY`** from `local.properties` → `build.gradle.kts`
3. **`BuildConfig.TMDB_API_KEY_FALLBACK`** from `local.properties` (only when `invalidate()` activates rotation)
4. **Empty** — degraded mode: catalog rows hidden, metadata sourced from addons only

## Fallback Rotation (Thread-Safe)

```
TmdbApiKeyHolder
├── @Volatile cachedKey: String     ← currently active key
├── @Volatile useFallback: Boolean  ← rotation flag
├── peek() → cachedKey              ← sync, no suspend
├── refresh() → cycles to primary (unless useFallback), updates cachedKey
└── invalidate() → toggles useFallback, swaps cachedKey between primary/fallback
```

On 401/403 from TMDB API:
1. Auth interceptor calls `keyHolder.invalidate()` → swaps to fallback key
2. Next API call uses fallback (transient)
3. Subsequent `refresh()` cycles back to primary

## Settings UI Flow

```
TmdbSettingsScreen
├── Shows current state: hasActiveKey, buildConfigConfigured, savedKeyMasked
├── TextField for user key input
├── [Save] → preferencesManager.setTmdbApiKey(key) → keyHolder.refresh()
├── [Clear] → preferencesManager.setTmdbApiKey(null) → keyHolder.invalidate()
└── Status message: "TMDB API key saved." / "Using build-time TMDB key only."
```

## Verification Commands

```bash
# Check local.properties has keys
grep 'TMDB_API_KEY' ~/Unspooled/local.properties

# Verify generated BuildConfig
grep 'TMDB_API_KEY' ~/Unspooled/app/build/generated/source/buildConfig/debug/com/unspooled/app/BuildConfig.java

# Confirm no secrets in APK strings (build-time key IS in BuildConfig, which IS in APK)
strings ~/Unspooled/app/build/outputs/apk/debug/app-debug.apk | grep 'TMDB_API_KEY'
# NOTE: BuildConfig.TMDB_API_KEY WILL appear in APK strings — that's unavoidable with
# buildConfigField. The defense is that local.properties is gitignored so the key is
# per-machine, not committed.
```

## Key Design Decisions

- **`@Volatile` on `cachedKey`** — allows `peek()` without suspend or locks. Safe because reads of `String` (immutable reference) are atomic on JVM.
- **`invalidate()` does NOT call refresh()** — keeps the swap instant. The caller (auth interceptor) handles the retry.
- **User key stored in DataStore** (not Keystore) — TMDB key is lower sensitivity than debrid tokens. Plaintext DataStore is acceptable; EncryptedSharedPreferences migration tracked as future work.
- **`ApiSecretsProvider` separates sync `tmdbApiKeyBlocking()` from suspend `tmdbApiKey()`** — blocking version used in `TmdbApiKeyHolder` constructor (no coroutine scope); suspend version used everywhere else for proper DataStore read.
