# Unspooled Full-Stack Audit — Discovery & Fix Reference

## Runtime Catalog Path (after backend removal)

### Home Screen
```
HomeViewModel.loadHome()
  ├── Priority rows (Continue Watching, Watchlist, hero) ← local data, no network
  ├── nativeDefinitions = filterCatalogDefinitions(TmdbCatalogRepository.HOME_CATALOGS)
  ├── addonDefinitions = buildAddonCatalogDefinitions()
  │     ├── addonRepository.getCatalogAddons()  ← healthy enabled catalog addons
  │     ├── manifest.catalogs[]                 ← each addon's advertised catalogs
  │     ├── shouldShowOnHome()                  ← UTILITY_CATALOG_BLOCKLIST filter
  │     └── sort by user order preferences
  ├── finalDefinitions = nativeDefinitions + addonDefinitions (after source filter)
  ├── smart loading: nativeDefs first batch, addonDefs second batch
  └── loadCatalogRow() → addonRepository.getCatalogRows() → tiered fallback
        ├── Tier 1: TmdbCatalogRepository.getCatalogRow() (native TMDB API)
        ├── Tier 1b: TraktCatalogRepository / AniList
        ├── Tier 2: TMDB fallback for non-prefixed IDs
        └── Tier 3: Stremio addon /catalog/{type}/{id}.json
```

### Movies Tab
```
MoviesViewModel.loadMovies()
  ├── nativeDefs = tmdbCatalogRepository.getMoviesCatalogDefinitions()
  ├── addonDefs = buildAddonMovieCatalogDefinitions()  ← movie-type addon catalogs
  └── loadRow() → same tiered chain as Home
```

### TV Shows Tab
```
TvShowsViewModel.loadTvShows()
  ├── nativeDefs = tmdbCatalogRepository.getTvCatalogDefinitions()
  ├── addonDefs = buildAddonTvCatalogDefinitions()  ← series/tv-type addon catalogs
  └── loadRow() → same tiered chain
```

## Stream Resolution Path (full)

```
PlayerViewModel.onIntent(PlayContent)
  ├── addonRepository.getStreamsForContent(type, id)
  │     ├── buildMetaIdCandidates() ← IMDb first, then TMDB prefixed
  │     ├── Tier 1: ProviderRegistry.fetchAllStreams()
  │     ├── Tier 2: Free playback addons
  │     ├── Tier 3: Stremio stream addons (getStreamAddons())
  │     └── DebridEnricher.enrich() ← check cache status
  ├── sourceRanker.rankStreams() ← first ranking
  ├── PlaybackOrchestrator.prepare() ← SKIPS re-ranking when preferredStream!=null
  │     ├── Validate URLs by HEAD request
  │     ├── Resolve magnets via debrid service
  │     └── Emit Ready/Failed via preparationState flow
  └── On error → FallbackEngine.getFallbackStream() ← tiered fallback
        ├── Tier 1: debrid same resolution
        ├── Tier 2: debrid any resolution
        ├── Tier 3: free same resolution
        ├── Tier 4: free any resolution
        └── Exhausted when MAX_TOTAL_ATTEMPTS (10) reached
```

## AIOMeta Integration — Key Facts

- **Manifest URL:** `https://aiometadata.elfhosted.com/stremio/a4fc9e72-11b6-44f6-82bc-1239a7c0fe7e/manifest.json`
- **Catalog count:** 77 catalogs across movie, series, anime, anime.movie, anime.series, collection, other
- **Resources:** catalog, meta, subtitles
- **Priority:** 96 (must be above Cinemeta's 95 for rich meta resolution)
- **Utility catalogs to exclude from home:** search.*, people_search.*, calendar-videos, gemini.search
- **Anime catalogs:** mal.airing, anilist.trending, mal.top_*, custom.org_stremio_animecatalogs.*, mal.*decade_anime, mal.genres
- **Metadata richness:** AIOMeta provides Trakt ratings, Simkl scores, TVDB/MDBList metadata, cast with photos — significantly richer than Cinemeta's IMDb-only data

## Fixed Bugs — Reference Patterns

### FallbackEngine Free-Source Filter (H8)
**Before (broken):**
```kotlin
rs.stream.isDebridCached == preferDebrid  // false != false → false for null
```
**After (fixed):**
```kotlin
if (preferDebrid) rs.stream.isDebridCached == true else rs.stream.isDebridCached != true
```
Reason: `isDebridCached` is `Boolean?` — null means "unknown/not yet enriched". Free tier should accept both `false` and `null`. Debrid tier should only accept `true`.

### PlaybackOrchestrator Double-Ranking (H6)
**Before:** PlayerViewModel ranks streams, passes raw `Stream` list to orchestrator which ranks AGAIN → different order between caller and orchestrator.
**After:** When `preferredStream != null`, orchestrator skips `sourceRanker.rankStreams()` and preserves caller's order as synthetic `RankedStream` entries.
```kotlin
val ranked = if (preferredStream != null) {
    streams.mapIndexed { index, stream ->
        SourceRanker.RankedStream(stream = stream, rankScore = ..., resolutionLevel = 4)
    }
} else {
    sourceRanker.rankStreams(streams, addonHealth, preferences)
}
```

### PlayerViewModel Fallback Race Condition (C4)
**Before:** `onPlayerError()` launched `viewModelScope.launch {}` without cancellation. Flaky network → multiple errors → concurrent fallback coroutines interleaving.
**After:** Store `fallbackJob: Job?` and cancel before launching new one:
```kotlin
fun onPlayerError(playbackError: PlaybackException? = null) {
    fallbackJob?.cancel()
    fallbackJob = viewModelScope.launch { ... }
}
```

### Scraper Progressive Mode Dedup Bypass (C5)
**Before:** `executeProgressive()` called directly without `deduplicateRequest()`. Concurrent requests for same content fired full scraper cascades redundantly.
**After:** Wrapped in dedup:
```javascript
async function executeProgressive(type, id, options) {
    return deduplicateRequest(progressiveKey, () => executeProgressiveInner(type, id, options));
}
```

### Scraper L2 Cache Unbounded Growth (H7)
**Before:** SQLite cache grew without limit — only expired entries were cleaned. Random-ID attacks could fill disk.
**After:** Added `CACHE_L2_MAX_ENTRIES` (default 50000) with oldest-entry trimming in the 5-minute cleanup interval:
```javascript
const countResult = db.prepare('SELECT COUNT(*) as cnt FROM stream_cache').get();
if (countResult.cnt > MAX_L2_ENTRIES) {
    db.prepare('DELETE FROM stream_cache WHERE cache_key IN (SELECT cache_key FROM stream_cache ORDER BY created_at ASC LIMIT ?)').run(excess);
}
```

## Shell Verification Commands

```bash
# Check if secrets leaked into APK
strings app/build/outputs/apk/debug/app-debug.apk | grep -c '76697eeb\|74f0994f\|X245A4XAIBGVM'

# Verify worker is reachable
curl -s -o /dev/null -w '%{http_code}' 'https://unspooled-config-v3.tw24fr.workers.dev/api/addons/url-sessions' -X POST -H 'Content-Type: application/json' -d '{}'

# Verify AIOMeta manifest loads
curl -s 'https://aiometadata.elfhosted.com/stremio/a4fc9e72-11b6-44f6-82bc-1239a7c0fe7e/manifest.json' | python3 -c "import json,sys; m=json.load(sys.stdin); print(f'{len(m[\"catalogs\"])} catalogs')"

# Verify local.properties has required secrets
grep -c 'TRAKT_CLIENT_ID\|TRAKT_CLIENT_SECRET\|RD_CLIENT_ID' local.properties

# Banned-pattern scan
grep -rn 'moveFocus(' app/src/main/java/ --include='*.kt' | grep -v '//'
```

## Known Limitations (Not Yet Fixed)

- **H3: Torrentio tokens in URL path** — Torrentio API requires `realdebrid=TOKEN` in the URL path. Unavoidable; documented in `DebridService.kt`.
- **H4: Trakt keys in plaintext DataStore** — `PreferencesManager` stores client ID/secret in Android DataStore XML. TODO: migrate to EncryptedSharedPreferences or Android Keystore.
- **H9: Media not paused before fallback** — `onPlayerError` calls `playStream` without `playerManager.pause()` first. Low-risk; player state machine handles it.
- **H10: `saveProgress` double-computes progress** — Called from both position collector AND lifecycle observer. Redundant but harmless.
- **H19: Safety timeout doesn't cancel in-flight debrid resolves** — `delay(130_000L)` fires even if orchestrator already resolved. Minor; `resolved` flag prevents double-emission.
- **H20: `ratingPromptJob` not cancelled on new content** — If user plays content A, skips to content B, A's prompt may fire after B starts. Minor UX issue.
- **H28: `saveProgress` hangs on corrupted DataStore** — No timeout on DataStore read. Very rare; DataStore corruption is unlikely.
