OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed / test_vidcore_reputation.py

115 lines · python ref:
"""Reputation store: a resolve that merely ran out of time must never be
recorded as "vidcore does not have this title".

Regression cover for the 2026-08-10 X-Men '97 failures. The resolver's only
"no url" exit was `no m3u8 surfaced`, which fires both when vidcore genuinely
lacks a title AND when the poll window (6 rounds, ~20s) simply expired first.
The addon filed both as a miss and then hid the stream row for 7 days. Measured
that day: S2E7 missed on a 57s cold resolve and hit 9s later; S2E8 and
tmdb:287238 S1E5 were both sitting in the store as misses while resolving fine
in 11.3s and 6s respectively -- i.e. the store had accumulated false entries,
and with fmovies+vsembed breaker-tripped since Jul 28 a hidden row means the
title has no sources at all.

Run:  .venv/bin/python3 -m pytest test_vidcore_reputation.py -q
"""

import os
import tempfile
import time

import pytest

import addon


@pytest.fixture(autouse=True)
def isolated_store(monkeypatch):
    """Never touch the live vidcore_reputation.json."""
    fd, path = tempfile.mkstemp(suffix=".json")
    os.close(fd)
    monkeypatch.setattr(addon, "VIDCORE_REP_FILE", path)
    monkeypatch.setattr(addon, "_vidcore_rep", {})
    yield
    os.unlink(path)


KEY = "series:tmdb:138502:2:8"


# --- classifying a resolver result -----------------------------------------
# "hit"  -> vidcore served a url
# "miss" -> vidcore positively has nothing (page never left FETCHING YOUR
#           CONTENT..., or there is no TMDB match at all)
# None   -> inconclusive; stays retryable and is NOT written to the store

def test_url_is_a_hit():
    assert addon._rep_outcome({"streams": [{"url": "https://x/master.m3u8"}]}) == "hit"


def test_absent_is_a_miss():
    assert addon._rep_outcome({"error": "no m3u8 surfaced", "reason": "absent"}) == "miss"


def test_timeout_is_inconclusive():
    """THE BUG: the poll window expiring is not evidence of absence."""
    assert addon._rep_outcome({"error": "no m3u8 surfaced", "reason": "timeout"}) is None


def test_unknown_reason_is_inconclusive():
    """Fail safe: an unrecognised reason must not blacklist a title."""
    assert addon._rep_outcome({"error": "boom"}) is None


# --- how many strikes before a row is hidden --------------------------------

def test_one_absent_does_not_hide():
    """A single cold/slow run must not cost the title a week."""
    addon._rep_record(KEY, "miss")
    assert addon._rep_is_known_miss(KEY) is False


def test_two_absents_hide():
    addon._rep_record(KEY, "miss")
    addon._rep_record(KEY, "miss")
    assert addon._rep_is_known_miss(KEY) is True


def test_a_hit_clears_the_strikes():
    addon._rep_record(KEY, "miss")
    addon._rep_record(KEY, "hit")
    addon._rep_record(KEY, "miss")
    assert addon._rep_is_known_miss(KEY) is False


def test_strikes_expire_with_the_ttl():
    """Two misses a fortnight apart are not two consecutive misses."""
    addon._rep_record(KEY, "miss")
    addon._vidcore_rep[KEY]["ts"] = time.time() - (addon.VIDCORE_MISS_TTL + 60)
    addon._rep_record(KEY, "miss")
    assert addon._rep_is_known_miss(KEY) is False


# --- entries written under the old conflated rule ---------------------------

def test_legacy_entries_are_not_trusted():
    """Pre-fix entries carry no strike count and cannot be distinguished from
    false misses -- two of the three checked on 2026-08-10 were false. They must
    re-probe rather than keep hiding a playable title."""
    addon._vidcore_rep[KEY] = {"status": "miss", "ts": time.time()}
    assert addon._rep_is_known_miss(KEY) is False


# --- the resolver contract this depends on ----------------------------------

def test_resolver_reports_a_reason():
    """addon._rep_outcome is only correct if the resolver actually emits
    `reason`; assert the source carries every reason the addon branches on."""
    mjs = os.path.join(os.path.dirname(os.path.abspath(addon.__file__)),
                       "node_helper", "vidcore_resolve.mjs")
    with open(mjs) as f:
        src = f.read()
    assert "reason" in src
    for reason in ("absent", "timeout"):
        assert f"'{reason}'" in src or f'"{reason}"' in src