mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-10 19:22:08 +00:00
Covers RemoteClient/RemoteService, REST routes, handlers, SessionStore, InputBridge, DownloadQueueManager, errors, compression, and serve CLI. E2e tier opts in via --live and can auto-spawn its own serve.
142 lines
5.9 KiB
Python
142 lines
5.9 KiB
Python
"""E2E: track quality probing per service.
|
|
|
|
Sends a session_create with the full track-selection knob set (range_,
|
|
vcodec, best_available) so the server returns every available track.
|
|
Picks S01E01 for series, first title for movies, then asserts that the
|
|
discovered video tracks meet the expected_quality limits declared in
|
|
fixtures.yaml. If no expected block is present, the test prints what was
|
|
discovered and skips so you can copy values back into the fixture.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
import pytest
|
|
|
|
pytestmark = [pytest.mark.live, pytest.mark.slow]
|
|
|
|
|
|
def _create_session(http_session, server_url: str, service: str, title_id: str) -> str:
|
|
payload = {
|
|
"service": service,
|
|
"title_id": title_id,
|
|
# Quality knobs — send enum names the server's session_create parser accepts.
|
|
"range_": ["SDR", "HDR10", "DV"],
|
|
"vcodec": ["AVC", "HEVC"],
|
|
"best_available": True,
|
|
}
|
|
r = http_session.post(f"{server_url}/api/session/create", json=payload, timeout=120)
|
|
if r.status_code >= 400:
|
|
pytest.skip(f"{service}: session create failed {r.status_code}: {r.text[:200]}")
|
|
return r.json()["session_id"]
|
|
|
|
|
|
def _wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 120.0):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30)
|
|
if r.status_code == 200:
|
|
return r.status_code, r.json()
|
|
if r.status_code == 400:
|
|
try:
|
|
body = r.json()
|
|
except Exception:
|
|
body = {}
|
|
if (body.get("details") or {}).get("auth_status") in ("authenticating", "pending_input"):
|
|
time.sleep(2.0)
|
|
continue
|
|
return r.status_code, r.json() if r.text else {}
|
|
return 408, {"message": "auth timeout"}
|
|
|
|
|
|
def _pick_target_title(titles: list[dict[str, Any]], season: int = 1, episode: int = 1) -> Optional[dict[str, Any]]:
|
|
"""Pick the configured target episode; fall back to the first title."""
|
|
for t in titles:
|
|
if t.get("type") == "episode" and t.get("season") == season and t.get("number") == episode:
|
|
return t
|
|
return titles[0] if titles else None
|
|
|
|
|
|
def _summarize(video_tracks: list[dict[str, Any]]) -> dict[str, Any]:
|
|
max_height = max((v.get("height") or 0 for v in video_tracks), default=0)
|
|
max_width = max((v.get("width") or 0 for v in video_tracks), default=0)
|
|
codecs = sorted({v.get("codec") for v in video_tracks if v.get("codec")})
|
|
ranges = sorted({v.get("range") for v in video_tracks if v.get("range")})
|
|
bitrates = sorted({v.get("bitrate") for v in video_tracks if v.get("bitrate")})
|
|
return {
|
|
"track_count": len(video_tracks),
|
|
"max_width": max_width,
|
|
"max_height": max_height,
|
|
"codecs": codecs,
|
|
"ranges": ranges,
|
|
"bitrate_min_kbps": min(bitrates) if bitrates else None,
|
|
"bitrate_max_kbps": max(bitrates) if bitrates else None,
|
|
}
|
|
|
|
|
|
def _resolve_title_id(conf: dict) -> Optional[str]:
|
|
"""Prefer series_url so S01E01 logic kicks in; fall back to movie title_url."""
|
|
return conf.get("series_url") or conf.get("title_url")
|
|
|
|
|
|
def test_track_quality_meets_expected(http_session, server_url: str, service_case, capsys) -> None:
|
|
service, conf = service_case
|
|
title_input = _resolve_title_id(conf)
|
|
if not title_input:
|
|
pytest.skip(f"{service}: no title_url/series_url in fixture")
|
|
|
|
sid = _create_session(http_session, server_url, service, title_input)
|
|
try:
|
|
code, body = _wait_for_titles(http_session, server_url, sid)
|
|
if code != 200:
|
|
pytest.skip(f"{service}: titles fetch failed {code}: {str(body)[:200]}")
|
|
|
|
titles = body.get("titles") or []
|
|
target = _pick_target_title(titles, season=conf.get("target_season", 1), episode=conf.get("target_episode", 1))
|
|
if not target:
|
|
pytest.skip(f"{service}: no titles returned")
|
|
|
|
# Movies appear with type=='movie'; episodes with type=='episode'.
|
|
kind = target.get("type")
|
|
title_id = target.get("id")
|
|
if not title_id:
|
|
pytest.skip(f"{service}: target title has no id")
|
|
|
|
r = http_session.post(
|
|
f"{server_url}/api/session/{sid}/tracks",
|
|
json={"title_id": title_id},
|
|
timeout=300,
|
|
)
|
|
if r.status_code != 200:
|
|
pytest.skip(f"{service}: tracks fetch failed {r.status_code}: {r.text[:200]}")
|
|
|
|
tracks = r.json()
|
|
video = tracks.get("video") or []
|
|
summary = _summarize(video)
|
|
# Print summary even when test passes — discovery aid.
|
|
with capsys.disabled():
|
|
print(f"\n[{service}] target={kind} '{target.get('name')}' -> {summary}")
|
|
|
|
expected = conf.get("expected_quality") or {}
|
|
if not expected:
|
|
pytest.skip(f"{service}: no expected_quality in fixtures.yaml — discovered={summary}")
|
|
|
|
if "min_height" in expected:
|
|
assert summary["max_height"] >= expected["min_height"], (
|
|
f"{service}: max height {summary['max_height']} < expected min {expected['min_height']}"
|
|
)
|
|
if "min_codecs" in expected:
|
|
missing = set(expected["min_codecs"]) - set(summary["codecs"])
|
|
assert not missing, f"{service}: missing codecs {missing}, got {summary['codecs']}"
|
|
if "min_ranges" in expected:
|
|
missing = set(expected["min_ranges"]) - set(summary["ranges"])
|
|
assert not missing, f"{service}: missing ranges {missing}, got {summary['ranges']}"
|
|
if "min_track_count" in expected:
|
|
assert summary["track_count"] >= expected["min_track_count"], (
|
|
f"{service}: only {summary['track_count']} video tracks, expected >= {expected['min_track_count']}"
|
|
)
|
|
finally:
|
|
http_session.delete(f"{server_url}/api/session/{sid}", timeout=30)
|