# Addon Health Monitor / Smoke Test System

## Problem

Stremio addons (Torrentio, Comet, MediaFusion, etc.) drift over time — domains expire, instances get retired, Cloudflare blocks from Workers, or they require debrid config. The Android TV app should not silently show empty catalogs or broken streams when an addon is down.

## Solution

Deploy a **smoke test endpoint** on the Cloudflare Worker that periodically tests each addon:

1. Loads the manifest JSON
2. Tests stream endpoints with known TMDB IDs
3. Tracks results with timestamps
4. Exposes a health status API the app can query

## Architecture

```
Android TV                 CF Worker                      Target Addon
    │                          │                              │
    │  GET /api/v1/smoke-test/status                         │
    │─────────────────────────►│                              │
    │◄── [{addon states...}]──│                              │
    │                          │                              │
    │  POST /api/v1/smoke-test/run                           │
    │  { addons: [...], testIds: [...] }                     │
    │─────────────────────────►│                              │
    │                          ├─── GET /manifest.json ─────►│
    │                          │◄── manifest ────────────────│
    │                          ├─── GET /stream/{type}/{id} ─►│
    │                          │◄── streams ─────────────────│
    │◄── [{updated states}]───│                              │
```

## Addon state machine

```typescript
type AddonState =
  | "Ready"            // manifest + streams returned ≥1 result recently
  | "NoResults"        // manifest OK, streams return 0 results
  | "NeedsConfig"      // requires debrid API key or config to work
  | "Degraded"         // manifest loads but streams fail or are very slow
  | "Failed"           // manifest 403/404, unreachable
  | "Disabled"         // user disabled this addon
```

## Data model per addon

```typescript
interface AddonHealth {
  id: string;                    // "torrentio-lite"
  name: string;                  // "Torrentio Lite"
  manifestUrl: string;           // "https://torrentio.strem.fun/lite/manifest.json"
  state: AddonState;
  
  manifestStatus: "ok" | "error";
  streamStatus: "ok" | "no_results" | "error";
  responseTimeMs: number | null;  // manifest load time
  resultCount: number | null;     // total streams returned
  lastSuccessfulResultAt: number | null;
  lastError: string | null;
  
  supportsMovie: boolean;
  supportsSeries: boolean;
  supportsAnime: boolean;
  requiresConfig: boolean;
  usableWithoutConfig: boolean;
  
  priority: number;               // 1 = highest, for source ordering
  enabled: boolean;
  lastTestedAt: string;           // ISO timestamp
}
```

## Test ID format

Uses TMDB IDs via Stremio addon convention (`metaId`):

```typescript
const TEST_IDS = [
  "movie/tt0133093",    // The Matrix
  "movie/tt1375666",    // Inception
  "movie/tt0111161",    // The Shawshank Redemption
  "movie/tt0468569",    // The Dark Knight
  "series/tt0903747:1:1", // Breaking Bad S1E1
  "series/tt0944947:1:1", // Game of Thrones S1E1
];
```

## Manifest testing logic

```typescript
async function testManifest(url: string): Promise<{
  manifestStatus: "ok" | "error";
  manifest: any | null;
  responseTimeMs: number;
  error?: string;
}> {
  const start = Date.now();
  try {
    const resp = await fetch(url, {
      headers: { "User-Agent": "Unspooled-SmokeTest/1.0" },
      signal: AbortSignal.timeout(10000),
    });
    const elapsed = Date.now() - start;
    if (!resp.ok) {
      return { manifestStatus: "error", manifest: null, responseTimeMs: elapsed, error: `HTTP ${resp.status}` };
    }
    const text = await resp.text();
    if (!text.trim().startsWith("{")) {
      return { manifestStatus: "error", manifest: null, responseTimeMs: elapsed, error: "Not JSON" };
    }
    const manifest = JSON.parse(text);
    if (!manifest.id || !manifest.name) {
      return { manifestStatus: "error", manifest: null, responseTimeMs: elapsed, error: "Missing id/name" };
    }
    return { manifestStatus: "ok", manifest, responseTimeMs: elapsed };
  } catch (e) {
    return { manifestStatus: "error", manifest: null, responseTimeMs: Date.now() - start, error: (e as Error).message };
  }
}
```

## Stream testing logic

```typescript
async function testStreams(manifestUrl: string, testIds: string[]): Promise<{
  streamStatus: "ok" | "no_results" | "error";
  resultCount: number;
  supportsMovie: boolean;
  supportsSeries: boolean;
  supportsAnime: boolean;
  error?: string;
}> {
  const baseUrl = manifestUrl.replace(/\/manifest\.json$/, "");
  let totalResults = 0;
  const supports = { movie: false, series: false, anime: false };

  for (const testId of testIds) {
    try {
      const resp = await fetch(`${baseUrl}/stream/${testId}.json`, {
        headers: { "User-Agent": "Unspooled-SmokeTest/1.0" },
        signal: AbortSignal.timeout(10000),
      });
      if (resp.ok) {
        const body = await resp.json();
        const streams = body.streams || [];
        totalResults += streams.length;
        const [type] = testId.split("/");
        if (type === "movie") supports.movie = true;
        else if (type === "series") supports.series = true;
        // anime detection from test ID pattern or manifest
      }
    } catch {
      // Individual test ID failures don't fail the whole addon
    }
  }
  
  if (totalResults === 0) {
    return { streamStatus: "no_results", resultCount: 0, ...supports, error: checkDebridBlocking() };
  }
  return { streamStatus: "ok", resultCount: totalResults, ...supports };
}
```

## State classification from results

```typescript
function classifyState(result: SmokeTestResult): AddonState {
  if (!result.requiresConfig && result.manifestStatus === "error") return "Failed";
  if (result.requiresConfig && result.manifestUrl === "") return "NeedsConfig";
  if (result.manifestStatus === "ok" && result.streamStatus === "ok" && result.resultCount! > 0) return "Ready";
  if (result.manifestStatus === "ok" && result.streamStatus === "no_results") return "NoResults";
  if (result.manifestStatus === "ok" && result.streamStatus === "error") return "Degraded";
  return "Failed";
}
```

## Source priority based on health

```typescript
interface AddonPriority {
  id: string;
  state: AddonState;
  priority: number; // 1 = highest
}

// Only Ready and NeedsConfig addons are used as sources
// Failed/NoResults/Degraded addons are skipped UNLESS they're the only option

function getUsableAddons(healthList: AddonHealth[]): AddonHealth[] {
  return healthList
    .filter(a => a.enabled && (a.state === "Ready" || a.state === "NeedsConfig"))
    .sort((a, b) => a.priority - b.priority);
}
```

## Verifying smoke test results

```bash
# Check current health status
curl -s https://your-worker.tw24fr.workers.dev/api/v1/smoke-test/status

# Run a new smoke test (takes ~30s)
curl -s -X POST https://your-worker.tw24fr.workers.dev/api/v1/smoke-test/run \
  -H "Content-Type: application/json" \
  -d '{
    "addons": ["torrentio-lite", "comet", "mediafusion", "aiostreams"],
    "testIds": [
      "movie/tt0133093", "movie/tt1375666",
      "movie/tt0111161", "movie/tt0468569",
      "series/tt0903747:1:1", "series/tt0944947:1:1"
    ]
  }' | python3 -m json.tool
```

## Common failure modes

| Error | Likely cause | Fix |
|-------|-------------|-----|
| `Manifest HTTP 403` | Cloudflare blocked the Worker IP for scraping | No fix (addon blocks Workers). Mark as Failed. |
| `Stream HTTP 403` | Manifest loads but streams require debrid or block Workers | Mark as NoResults (or RequiresConfig if applicable). |
| `Stream HTTP 404` | Addon doesn't support series streaming (manifest may only declare movies) | Mark `supportsSeries=false`. |
| `Not JSON` | Addon endpoint serves an HTML landing/deprecation page instead of JSON | Addon is deprecated. Mark as Failed. |
| `ETIMEDOUT` | Addon instance is overloaded or dead | Mark as Degraded. Re-test on next run. |

## Integration into stream fetching pipeline

When the Android app fetches streams for a title, it should:

1. Query smoke test status to get usable addons
2. Try addons in priority order (AIOStreams → Torrentio → Comet → MediaFusion)
3. Skip addons in `Failed`, `NoResults`, or `Disabled` state
4. Fall back to remaining addons if no streams found
5. Cache health status for 5-15 minutes to avoid per-request delays
