# Catalog & Addon Architecture Audit (2026-06-02, updated)

## Summary

Catalog backend (`CatalogBackendRepository`/`Api`/`Module`) fully removed. Architecture is now hybrid:
1. **Native TMDB** — hardcoded row taxonomy, direct TMDB API calls via user's API key
2. **Manifest-driven addon catalogs** — reads `manifest.catalogs[]` from enabled Stremio addons, fetches via `/catalog/{type}/{id}.json`

## Catalog Runtime Paths

### Path A: Native TMDB (Movies/TV/Home pages)
```
MoviesViewModel.loadMovies()
  → tmdbCatalogRepository.getMoviesCatalogDefinitions()  // hardcoded list
  → buildMovieContent(defs)
    → loadRow(def.rowId, def.title, def.catalogType, def.catalogId, def.extra, "tmdb")
      → tmdbCatalogRepository.getCatalogRow(type, id, extra)
        → getCatalogRowInternal(key, type, id, extra)
          → tmdbApi.getTrending() / getPopularMovies() / discoverMovies() etc.
```

### Path B: Addon Catalogs (Home screen)
```
HomeViewModel.loadHome()
  → buildAddonCatalogDefinitions(orderKeys, disabledKeys)
    → addonRepository.getCatalogAddons()  // healthy enabled addons with "catalog" resource
    → for each addon: manifest.catalogs[]
    → shouldShowOnHome()  // excludes search catalogs
    → sort by user preferences
    → map to TmdbCatalogDefinition (with anime source/rowType)
  → Smart loading: nativeDefs.partition() — native first batch, addons second batch
  → loadCatalogRowForDefinition(def)
    → loadCatalogRow(rowId, title, type, catalogType, catalogId, extra)
      → addonRepository.getCatalogRows(type, catalogId, extra)  // AddonRepository.kt:480
        → Tier 1: tmdbCatalog.getCatalogRow() for tmdb.* prefixed IDs
        → Tier 1b: traktCatalog for trakt.* IDs
        → Tier 1c: aniListCatalog for anilist.* IDs
        → Tier 2: tmdbCatalog fallback for unprefixed IDs
        → Tier 3: client.getCatalog(addon, type, catalogId, extra) for each catalog addon
          → StremioAddonClientImpl.getCatalog()
            → GET {addonBaseUrl}/catalog/{type}/{id}.json (or .../key=val.json)
```

### Path C: Addon Install (QR / URL / Text input)
```
AddonsViewModel.installAddon(manifestUrl)
  → addonRepository.addAddon(manifestUrl)
    → client.loadManifest(manifestUrl)  // fetch manifest.json
    → unifiedAddonStore.insertOrUpdateUserAddon(instance)
    → addonRegistry.setAddonEnabled(id, true)
    → refresh addon state flows
```

### Path D: QR Addon URL Entry (Cloudflare Worker)
```
AddonsViewModel.startAddonUrlQrMode()
  → addonUrlApiClient.createSession()
    → POST https://unspooled-config-v3.tw24fr.workers.dev/api/addons/url-sessions
    ← {code, status: "pending", formUrl}
  → QrCodeGenerator.generate(entryUrl, 400)
  → Poll: GET /api/addons/url-sessions/{code}
    ← status: "completed", configuredUrl: "https://..."
  → addonRepository.addAddon(configuredUrl)
```

### Path E: Local Addon Config Server (LAN QR)
```
AddonsViewModel.startQrMode()
  → AddonConfigServer.startOnAvailablePort(port=8080)
  → Shows QR: http://{deviceIP}:{port}
  → Phone browser → /api/state → /api/addons (POST urls)
  → handleQrChange() → confirmQrChange() → addonRepository.addAddon()/removeAddon()
```

## System Addon Seeds (current)

| ID | Name | Type | Priority | Default |
|----|------|------|----------|---------|
| `tmdb` | TMDB (Native) | SYSTEM_CATALOGS | 100 | enabled |
| `aiometadata` | AIOMetadata | SYSTEM_METADATA | **96** | enabled |
| `cinemeta` | Cinemeta | SYSTEM_METADATA | 95 | enabled |
| `opensubtitles` | OpenSubtitles v3 | SYSTEM_SUBTITLES | 80 | enabled |
| `more_like_this` | More Like This | SYSTEM_RECOMMENDATIONS | 70 | disabled |
| `nexstream_scraper` | NexStream Scraper | OPTIONAL_DEBRID_ADDONS | 15 | enabled |

**AIOMeta priority is 96 (above Cinemeta's 95).** This is intentional: AIOMeta has richer metadata (Trakt/Simkl/TVDB/MDBList ratings); Cinemeta is IMDb-only. If Cinemeta resolves first in `resolveMetaItem()`, rich metadata is silently dropped.

**Removed seeds** (purged via `REMOVED_SYSTEM_IDS`):
- `knightcrawler`, `annatar`, `torrentio`, `mediafusion`, `comet`, `jackettio`
- `tmdb_stremio`, `tmdb_collections`

## AIOMeta Catalog Inventory (77 catalogs)

AIOMeta at `https://aiometadata.elfhosted.com/stremio/a4fc9e72.../manifest.json` provides:
- **tmdb.* — 4 catalogs**: top_series, top_movie, year_movie/series, language_movie/series
- **mdblist.* — 39 catalogs**: Seasonal, Trakt Trending, Latest Airing/Digital Release, Kids, Netflix/Prime/Apple/Disney/Paramount/HBO/Hulu Latest, genre rows (Action, Comedy, Crime, Drama, Horror, Sci-Fi, Thriller, Documentary, Animation, etc.)
- **tvdb.* — 3 catalogs**: genres_movie/series, collections_movie
- **mal.* — 6 catalogs**: Airing Now, Top Series/Movies/AllTime, Decade lists
- **anilist.* / custom.* — 3+ catalogs**: AniList Trending, AniDB Latest Ended, anime catalogs
- **streaming.* — 2 catalogs**: Crunchyroll Latest
- **search.* — 8 catalogs**: Movies/Series/Anime/TVDB Collections/People searches
- **calendar-videos — 1 catalog**
- **people_search.* — 2 catalogs**

## Worker URLs (DON'T GUESS THESE)

| Config Key | Value | Verified |
|-----------|-------|----------|
| `ADDON_CONFIG_WORKER_URL` | `https://unspooled-config-v3.tw24fr.workers.dev` | ✅ 200 on `/api/addons/url-sessions` |
| `ADDON_AUTH_WORKER_URL` | `https://unspooled-config-v3.tw24fr.workers.dev` | ✅ same worker handles both |
| `NEXSTREAM_SCRAPER_URL` | `http://192.168.1.50:3091` | ✅ local server |

**Both ADDON_CONFIG and ADDON_AUTH URLs default to the SAME worker** (`unspooled-config-v3`). The worker handles config sessions, addon URL QR entry, and debrid auth sessions. BuildConfig comment explains this.

**Dead domains (DO NOT USE):**
- `addon-config.unspooled.workers.dev` — DNS doesn't resolve
- `unspooled-auth-v2.tw24fr.workers.dev` — may still exist but config-v3 handles both

## Key Patterns Learned

### Cross-Source Dedup (tmdb: vs tt prefix)
Different catalog sources use different ID schemes:
- Native TMDB: `tmdb:12345`
- Stremio/Cinemeta/AIOMeta: `tt0123456` (IMDb)

`crossRowDedupKey()` in HomeViewModel/MoviesViewModel/TvShowsViewModel must strip BOTH prefixes:
```kotlin
val stableId = item.id
    .removePrefix("tmdb:")
    .removePrefix("tt")
    .takeIf { it.isNotBlank() }
```

### Smart Loading Order
Native TMDB definitions (fast, local API key → sub-second) load first. Addon catalog definitions (external HTTP, slower) load second wave:
```kotlin
val (nativeDefs, addonDefs) = finalDefinitions.partition { def ->
    def.catalogId.startsWith("tmdb.") || def.catalogId.startsWith("trakt.") ||
        def.catalogId.startsWith("anilist.") || def.catalogId.startsWith("mdblist.")
}
val firstBatch = nativeDefs.take(INITIAL_CATALOG_BATCH_SIZE)
val secondBatch = nativeDefs.drop(INITIAL_CATALOG_BATCH_SIZE) + addonDefs
```

### AIOMeta Display Name Cleanup
`addonCatalogTitle()` strips `| ElfHosted` suffixes and recognizes well-known addons:
```kotlin
val addonName = rawAddonName
    .replace(Regex("""\s*\|\s*ElfHosted"""), "")
    .replace(Regex("""\s*\|\s*Community"""), "")
    .trim()
return when {
    addon.id == "aiometadata" -> catalogName  // no suffix needed
    addon.id == "cinemeta" -> catalogName
    catalogName.contains(addonName, ignoreCase = true) -> catalogName
    else -> "$catalogName — $addonName"
}
```

### Anime Catalog Support
AIOMeta provides `anime`, `anime.movie`, `anime.series` type catalogs. `buildAddonCatalogDefinitions()` maps them:
```kotlin
rowType = when (catalog.type) {
    "series", "tv" -> RowType.TRENDING
    "anime", "anime.movie", "anime.series" -> RowType.GENRE
    else -> RowType.GENRE
},
source = when {
    catalog.type.startsWith("anime") -> "anime"
    else -> null
}
```
And `getSourceForDefinition()` matches `mal.` and `anidb` patterns in addon IDs.

### CatalogManager Uses Native Definitions
`CatalogManagerViewModel` sources from `TmdbCatalogRepository` (not a backend):
```kotlin
private fun nativeCatalogsByCategory(): Map<String, List<CatalogDefinition>> {
    val home = tmdbCatalogRepository.getHomeCatalogDefinitions()...
    val movies = tmdbCatalogRepository.getMoviesCatalogDefinitions()...
    val shows = tmdbCatalogRepository.getTvCatalogDefinitions()...
    return (home + movies + shows)
        .distinctBy { "${it.type}:${it.id}:${it.category}" }
        .groupBy { it.category.ifBlank { "TMDB" } }
}
```

## How NuvioTV Catalogs Differ

NuvioTV has **zero hardcoded catalog rows.** Their `CatalogRepositoryImpl`:
1. Accepts addon base URL, catalog ID, type, skip, extra args
2. Builds URL: `{baseUrl}/catalog/{type}/{catalogId}.json` (or with encoded args)
3. Calls Stremio catalog endpoint
4. Returns `CatalogRow` with items

They don't have native TMDB. ALL catalogs come from addon manifests.

Unspooled's hybrid approach is intentionally different — native TMDB provides out-of-the-box basics; addon catalogs enrich.
