# Metadata Matching Layer — Full Reference

Implementation details for TMDB + TVDB backed matching, scoring, and conditional filtering in the Ultimate Scraper v5.

## Architecture

```
Stremio ID (tt0133093 or tt0944947:1:2)
  → parseStremioId() → { imdbId, tmdbId?, season?, episode? }
  → getMetadata() [TMDB]
       → fetchById(imdbId) or fetchByTmdbId(tmdbId, type)
       → getAliases() + getTranslations() → merged deduplicated list
       → getSeasons() for series → season -> episodeCount map
  → buildContext()
       → title, year, aliases[], imdbId, tmdbId, season, episode, type
```

For series, TVDB is available as backup:
```
validateEpisode(tvdbSeriesId, season, episode) → { valid, episodeName }
getSeasonEpisodes(tvdbSeriesId, seasonNumber) → [{ number, name, airDate }]
```

## Matching Rules (`matchResult()`)

### Stop Words (removed before Jaccard comparison)
```
the, a, an, and, or, of, in, on, at, to, for, with, is, it, by, from, as, are, was, were, its, be, has
```

### Sequel Indicator Tokens (rejected when present as extra words in title)
```
/\bii\b/, /\biii\b/, /\biv\b/, /\b2\b/, /\b3\b/, /\b4\b/,
/\breloaded\b/, /\brevolutions\b/, /\breturns\b/, /\brevenge\b/,
/\brising\b/, /\bdawn\b/, /\bdusk\b/, /\bpart\b/, /\bchapter\b/,
/\blegacy\b/, /\borigins\b/, /\bfinale\b/, /\bfinal\b/,
/\bextended\b/, /\buncut\b/, /\buncensored\b/,
/\bremake\b/, /\breboot\b/
```

### Movie Matching Matrix

| Similarity | Year match | Result |
|-----------|-----------|--------|
| ≥0.85 | ±1 | ACCEPT (score=sim) |
| ≥0.85 | ±2 | ACCEPT (score=sim×0.7) |
| ≥0.85 | >2 diff | REJECT — "Year mismatch" |
| ≥0.85 | no year | ACCEPT (score=sim) |
| 0.65-0.85 | any | ACCEPT (score=sim×0.4, Partial match) |
| <0.65 | any | REJECT — "Title mismatch" |
| any (alias match) | any | ACCEPT (score≥0.85) |
| all words match + sequel indicators | any | REJECT — "Wrong movie" |
| all words match + year diff >3 | any diff >3 | REJECT — "Wrong movie (remake/reboot)" |

### Series Matching Matrix

| Request | Torrent has | Result |
|---------|-------------|--------|
| `tt:1:2` | S1E2 exact | ACCEPT "Exact S1E2 match" |
| `tt:1:2` | S1 season pack | ACCEPT "Season pack S1 (contains E2)" |
| `tt:1:2` | S2E1 | REJECT "Wrong season" |
| `tt:1:2` | S1E3 | REJECT "Wrong episode" |
| `tt:1` | S1 pack | ACCEPT "Season pack S1" (score 0.9) |
| `tt:1` | S1E5 | ACCEPT "Single episode S1E5" (score 0.75) |
| `tt:1` | S2 pack | REJECT "Wrong season" |
| no S/E | any pack | ACCEPT "Series match: S1 pack" |
| no S/E | any episode | ACCEPT "Series title match" |
| any | title sim <0.65 | REJECT "Series title mismatch" |

## Scoring Weights (`scoreResults()`)

```
finalScore = streamScore + (matchScore * 15)

streamScore = RESOLUTION_SCORE[quality]
            + QUALITY_SCORE[source]
            + ENCODE_SCORE[codec]
            + HDR_BONUS
            + sum(AUDIO_BONUS)
            + (isCached ? 25 : 0)
            + (multiDebrid ? 5 : 0)
```

| Resolution | Score |
|-----------|-------|
| 4K / 2160p | 100 |
| 1440p | 80 |
| 1080p | 75 |
| 720p | 55 |
| 576p | 40 |
| 480p | 30 |
| SD | 20 |
| Unknown | 0 |

| Quality tier | Score | Example regex |
|-------------|-------|---------------|
| REMUX | 100 | `(?:BD\|BR)\s*REMUX`, `REMUX` |
| BluRay | 90 | `BluRay`, `BDRip`, `Blu-Ray` |
| WEB-DL | 80 | `WEB-DL`, `WEBRip`, `WEB` |
| WEBRip | 60 | `WEBRip`, `WEB-Rip` |
| HDRip | 50 | `HDRip`, `HC` |
| HDTV | 40 | `HDTV`, `PDTV` |
| DVD | 30 | `DVDRip`, `DVD-Rip` |
| CAM | 5 | `CAM`, `TS`, `TC` |

| Encode/codec | Score | Notes |
|-------------|-------|-------|
| HEVC / H.265 / x265 | 90 | Best compression, modern HW decode |
| AV1 | 80 | Best compression, poor HW decode support |
| AVC / H.264 / x264 | 70 | Widest device compatibility |
| VP9 | 65 | Web-optimized |
| XviD | 25 | Legacy |
| DivX | 20 | Legacy |

| HDR/visual | Bonus |
|-----------|-------|
| Dolby Vision (DV) | +15 |
| HDR10+ | +12 |
| HDR10 | +10 |
| HDR (generic) | +8 |
| IMAX | +3 |

| Audio codec | Bonus |
|------------|-------|
| Atmos | +5 |
| TrueHD | +4 |
| DTS-HD MA | +4 |
| DTS:X | +4 |
| DTS-HD (HRA) | +3 |
| FLAC | +3 |
| DTS-ES | +2 |
| DTS | +2 |
| DD+ (E-AC-3) | +2 |
| DD (AC-3) | +1 |
| OPUS | +1 |
| AAC | +1 |

## Conditional Filtering (`conditionalFilter()`)

Removes "bad" results (Unknown quality, no size, no seeders) only when ≥5 "good" results exist:

```js
function conditionalFilter(results) {
  const good = [], bad = [];
  for (const r of results) {
    const isBad = ['Unknown', 'CAM', 'SD'].includes(r.quality)
               || !r.size || r.size <= 0
               || (!r.seeders || r.seeders <= 0);
    if (isBad) bad.push(r); else good.push(r);
  }
  if (good.length >= 5 && bad.length > 0)
    return { valid: good, removed: bad.length };
  return { valid: [...good, ...bad], removed: 0 };
}
```

## TVDB v4 API Client

JWT-based auth (login with API key + PIN, token valid 24h, re-login every 12h):

```js
async function login() {
  const r = await fetch(`${TVDB_BASE}/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ apikey: config.TVDB_API_KEY, pin: config.TVDB_PIN }),
  });
  if (!r.ok) return null;
  _token = r.json().data.token;
  _tokenExpires = Date.now() + 12 * 60 * 60 * 1000;
  return _token;
}

async function apiFetch(path) {
  if (!isConfigured()) return null;
  const token = await login();
  if (!token) return null;
  const r = await fetch(`${TVDB_BASE}${path}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (r.status === 401) { _token = null; return apiFetch(path); } // retry once
  if (!r.ok) return null;
  return r.json();
}
```

Methods:
- `getSeriesByImdb(imdbId)` → search by IMDb ID, returns `{ tvdbId, name, year, status }`
- `getSeasonEpisodes(seriesTvdbId, seasonNumber)` → `[{ id, number, name, airDate }]`
- `validateEpisode(seriesTvdbId, season, episode)` → `{ valid, episodeName? }`
- `getSeriesInfo(tvdbId)` → extended info with seasons array

## Stream Formatter Tags (v3)

Stream `name` field format: `{RES_BADGE} 〈{QUALITY_TAG}〉 {CODEC}·{HDR}·{AUDIO}·{CHANNELS} {SIZE} 👤{SEEDERS}`

Examples from verified output:
```
 4K   ⏳ 〈BluRay〉 HEVC DV Atmos 11.54GB 👤7
 4K   ⏳ 〈Web-DL〉 HEVC·DV·5.1 21.13GB 👤21
 4K   ⏳ 〈BluRay〉 HEVC·HDR·Atmos·TrueHD·7.1 35.35GB 👤85
FHD   ⏳ 〈BluRay〉 HEVC·DD+·7.1 9.66GB 👤5
FHD   ⏳ 〈Web-DL〉 AVC 1.86GB 👤755
 HD   ⏳ 〈720〉 AV1 519.9MB 👤2019
```

Stream `description` field format (multi-line):
```
✎ Title (Year) [S1 E1]
CODEC · HDR · Edition · Source
AudioCodecs · Channels
Size · 👤 Seeders · ReleaseGroup
[Source] ★Score ⚙StreamScore
🌐 Languages 📄 Subtitles
```

## Env vars (never logged/committed)

```
TMDB_API_KEY=v3_api_key
TMDB_ACCESS_TOKEN=v4_bearer_token
TVDB_API_KEY=your_tvdb_api_key
TVDB_PIN=your_tvdb_pin
```

## Embed Sources Status (July 2026)

| Source | Method | Status |
|--------|--------|--------|
| SuperEmbed | `seapi.link` JSON API | 🔴 DEAD — HTTP 000, unreachable. Falls back to iframe |
| VidSrc | RC4 decrypt + AJAX API | 🟡 Works intermittently — returns m3u8 when successful |
| 2Embed | No extractor (iframe only) | 🔴 Always iframe — exclude for Nuvio TV |
| EmbedSu | No extractor (iframe only) | 🔴 Always iframe — exclude for Nuvio TV |
| VidBinge | No extractor (iframe only) | 🔴 Always iframe — exclude for Nuvio TV |

**Rule for Nuvio TV**: Only include embed streams whose extractors resolve to direct video URLs (m3u8, mp4). If an extractor fails or returns iframe, exclude that stream entirely. ExoPlayer cannot play HTML/iframe responses — error "None of the available extractors could read the stream (contentIsMalformed=false)".
