| … | … | @@ -517,6 +517,101 @@ def test_an_unidentifiable_title_offers_no_keep_row(triggered): |
| 517 | 517 | assert out == [] |
| 518 | 518 | |
| 519 | 519 | |
| 520 | # --- next-episode queueing -------------------------------------------------- |
| 521 | # The only trigger that fetches something the user did NOT directly choose, so |
| 522 | # it is the one that must know when to stop. Two independent bounds: TMDB says |
| 523 | # whether a next episode exists at all, and the reputation store says whether |
| 524 | # the source actually has it. |
| 525 | |
| 526 | @pytest.fixture |
| 527 | def rolling(monkeypatch, tmdb, tmp_path): |
| 528 | tmdb.responses["tv/1"] = {"name": "Show", "first_air_date": "2020-01-01"} |
| 529 | for e in (1, 2, 3): |
| 530 | tmdb.responses[f"tv/1/season/1/episode/{e}"] = {"name": f"E{e}"} |
| 531 | tmdb.responses["tv/1/season/1"] = { |
| 532 | "episodes": [{"episode_number": 1}, {"episode_number": 2}]} |
| 533 | tmdb.responses["tv/1/season/2"] = {"episodes": [{"episode_number": 1}]} |
| 534 | tmdb.responses["tv/1/season/3"] = {"episodes": []} |
| 535 | tmdb.responses["tv/1/season/2/episode/1"] = {"name": "S2E1"} |
| 536 | monkeypatch.setattr(addon, "MP4_DIR", os.path.join(str(tmp_path), "cache")) |
| 537 | os.makedirs(addon.MP4_DIR, exist_ok=True) |
| 538 | monkeypatch.setattr(addon, "_prefetch_vidcore", lambda *a, **k: None) |
| 539 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/m.m3u8") |
| 540 | monkeypatch.setattr(addon, "_cap_variant", lambda url, h: url) |
| 541 | monkeypatch.setattr(addon, "_vidcore_rep", {}) |
| 542 | queued = [] |
| 543 | monkeypatch.setattr(addon, "_start_mp4_job", |
| 544 | lambda *a, **k: queued.append(a) or "running") |
| 545 | return queued |
| 546 | |
| 547 | |
| 548 | def test_the_next_episode_in_a_season_is_found(rolling): |
| 549 | assert addon._next_episode("1", 1, 1) == (1, 2) |
| 550 | |
| 551 | |
| 552 | def test_the_end_of_a_season_rolls_into_the_next(rolling): |
| 553 | assert addon._next_episode("1", 1, 2) == (2, 1) |
| 554 | |
| 555 | |
| 556 | def test_the_end_of_the_series_stops(rolling): |
| 557 | """Season 3 exists in TMDB but is empty, and there is no season 4.""" |
| 558 | assert addon._next_episode("1", 2, 1) is None |
| 559 | |
| 560 | |
| 561 | def test_playing_an_episode_queues_the_next_one(rolling): |
| 562 | addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720") |
| 563 | assert (("series", "tmdb:1", 1, 2, 720)) in [tuple(q) for q in rolling] |
| 564 | |
| 565 | |
| 566 | def test_playing_a_library_copy_also_rolls_forward(rolling): |
| 567 | """Otherwise the rolling library stalls the moment you start watching from |
| 568 | it -- the local file is served without ever touching the vidcore route.""" |
| 569 | dest = addon._library_path("series", "Show", "2020", "1", |
| 570 | season=1, episode=1, episode_title="E1") |
| 571 | os.makedirs(os.path.dirname(dest), exist_ok=True) |
| 572 | open(dest, "wb").write(b"x" * 10) |
| 573 | addon.app.test_client().get("/mp4/series/tmdb:1:1:1.mp4?h=720") |
| 574 | assert (("series", "tmdb:1", 1, 2, 720)) in [tuple(q) for q in rolling] |
| 575 | |
| 576 | |
| 577 | def test_a_next_episode_the_source_lacks_is_not_queued(rolling): |
| 578 | """A known miss is a positive finding that vidcore does not have it. Queueing |
| 579 | it would spend a browser launch to rediscover that on every play.""" |
| 580 | addon._vidcore_rep[addon._rep_key("series", "tmdb:1", 1, 2)] = { |
| 581 | "status": "miss", "ts": addon.time.time(), "strikes": 2} |
| 582 | addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720") |
| 583 | assert [q for q in rolling if q[3] == 2] == [] |
| 584 | |
| 585 | |
| 586 | def test_an_inconclusive_next_episode_is_not_queued(rolling): |
| 587 | """Inconclusive must NOT harden into 'go and fetch it' either -- one strike |
| 588 | is not a finding. It stays queueable only once it is genuinely unknown, not |
| 589 | while a previous attempt was ambiguous.""" |
| 590 | addon._vidcore_rep[addon._rep_key("series", "tmdb:1", 1, 2)] = { |
| 591 | "status": "miss", "ts": addon.time.time(), "strikes": 1} |
| 592 | addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720") |
| 593 | nxt = [q for q in rolling if q[3] == 2] |
| 594 | assert nxt == [], "a pending strike should not trigger a speculative fetch" |
| 595 | |
| 596 | |
| 597 | def test_queueing_does_not_chain_beyond_one(rolling): |
| 598 | """Watching one episode must not enqueue a whole season.""" |
| 599 | addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720") |
| 600 | assert len([q for q in rolling if q[1] == "tmdb:1"]) <= 2 |
| 601 | |
| 602 | |
| 603 | def test_a_movie_never_queues_anything(rolling, tmdb): |
| 604 | tmdb.responses["movie/5"] = {"title": "Film", "release_date": "2020-01-01"} |
| 605 | addon.app.test_client().get("/vidcore/movie/tmdb:5.m3u8?h=720") |
| 606 | assert all(q[0] != "series" for q in rolling) |
| 607 | |
| 608 | |
| 609 | def test_a_tmdb_failure_stops_rolling_rather_than_guessing(rolling, monkeypatch): |
| 610 | monkeypatch.setattr(addon, "_tmdb_get", |
| 611 | lambda *a, **k: (_ for _ in ()).throw(RuntimeError("down"))) |
| 612 | assert addon._next_episode("1", 1, 1) is None |
| 613 | |
| 614 | |
| 520 | 615 | def test_ingest_resolves_at_run_time_not_at_queue_time(ingest, monkeypatch): |
| 521 | 616 | """Variant URLs are signed and expire in ~4h. A job that resolved when it |
| 522 | 617 | was queued would fetch a dead link once the queue backs up.""" |