From d2666b5c8e230562c5174e29183e91ab217442e9 Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:30:09 +0200
Subject: [PATCH 01/20] Let the portal say how it wants to be authenticated
The flow was chosen here, from whether a username and password happened to
be configured: credentials meant do_auth, their absence meant a device-ID
step whose failure was swallowed as a warning. That guess was wrong in both
directions. Portals that want a password were handed a device-ID step and
then went on to serve an empty channel list, which the plugin reported as a
probable wrong MAC -- and an account the provider had blocked looked exactly
the same, because nothing ever read the answer that said so.
The portal already states which one it wants. get_profile comes back with a
status: 0 is a session that is good, 2 is a request for credentials, and
anything else is a refusal carrying the provider's own block_msg or msg.
Follow it instead, and the refusal reaches the user in the words their
reseller wrote.
Two tolerances are deliberate, because most portals this meets are not
Ministra and answer with less than it would. A profile with no status at all
counts as good -- pvr.stalker calls that a failure, which would break every
clone that worked yesterday. A portal that does not answer get_profile at
all stays a warning and proceeds on the MAC alone; only an explicit refusal
is fatal, which is what PortalAuthError now marks.
That type also fixes a hole at tune time: an expired session is answered
with plain text, an HTTP 403, or a hollow create_link success, and only the
first of those was recognised. The resolver re-authenticates on the class
rather than on any failure, so a portal that is merely unreachable no longer
costs a pointless second round-trip before Dispatcharr fails over.
The profile finally carries the whole STB identity, signature included --
documented as a setting since 0.3.0 and present in no request until now.
device_id_auth goes with the guess it existed for; it was derived, never
written, so nothing needs migrating.
---
CHANGELOG.md | 30 +++++
CONTRIBUTING.md | 1 +
README.md | 23 +++-
plugin.py | 2 +-
resolver.py | 7 +-
stalker_api.py | 255 ++++++++++++++++++++++++++++--------
sync.py | 9 +-
tests/test_auth.py | 265 ++++++++++++++++++++++++++++++++++++++
tests/test_config.py | 18 ++-
tests/test_mock_portal.py | 32 ++++-
10 files changed, 559 insertions(+), 83 deletions(-)
create mode 100644 tests/test_auth.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3935371..87e20a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,35 @@
# Changelog
+## Unreleased
+
+**Connecting**
+
+- **The portal now decides which authentication it gets.** Distalker reads the
+ `status` its profile request comes back with and does what it asks: nothing
+ further when the session is already good, or `do_auth` followed by a second
+ profile call when the portal says it wants credentials. It used to pick the
+ flow itself from whether a username and password happened to be configured,
+ which was wrong in both directions — it ran a device-ID step at portals that
+ wanted a password, and had no way to tell a refused account from an empty
+ channel list.
+- A portal that refuses the account now says so in the provider's own words —
+ "subscription expired", "blocked" — instead of failing later as a channel
+ list that came back empty and a suggestion to check the MAC address.
+- A portal that wants credentials and has none on its line now fails the sync
+ with that as the message, rather than appearing to work.
+- Expired sessions are recognised from the plain-text `Authorization failed.`
+ some portals answer with, and from HTTP 401/403, instead of being reported as
+ a portal talking nonsense.
+- The box's profile now carries the full identity every other Stalker client
+ sends, `signature` included — which had been a documented setting that no
+ request ever contained, so setting it configured nothing.
+- Portals with no profile endpoint at all keep working on the MAC alone, with a
+ warning. That tolerance is deliberate: most portals this plugin meets are not
+ Ministra and answer with less than it would.
+- The `device_id_auth` line key is gone. Nothing needs to be changed: it was
+ never something to write on a portal line, only a value the plugin derived
+ for itself, and lines are re-read on every sync.
+
## 0.9.2
**Playing**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 181f568..9297aac 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,6 +33,7 @@ script with its own `__main__` block:
```bash
python3 tests/test_config.py # portal-line parsing, STB defaults, pseudo-URLs
+python3 tests/test_auth.py # which authentication a portal asks for
python3 tests/test_registry.py # surviving the settings panel
python3 tests/test_manifest.py # plugin.json vs plugin.py, and run()'s plumbing
python3 tests/test_fallback.py # non-portal sources on a Distalker channel
diff --git a/README.md b/README.md
index b991d7c..e7bf4dc 100644
--- a/README.md
+++ b/README.md
@@ -193,9 +193,9 @@ http://portal.example.com/c/ | 00:1A:79:AA:BB:CC | model=MAG322 timezone=Europe/
| --- | --- | --- |
| `model` | `MAG254` | Sent as the `X-User-Agent` device model. Also `MAG250`, `MAG322`, … |
| `serial` | `0000000000000` | Portal cookie `sn`. |
-| `device_id` | 64 × `f` | Sent during device-ID authentication. |
+| `device_id` | 64 × `f` | Sent with the box's profile, and with credentials. |
| `device_id2` | same as `device_id` | Most boxes carry the same value in both slots. |
-| `signature` | 64 × `f` | Accepted, but **currently unused**: no request Distalker makes includes it. |
+| `signature` | 64 × `f` | Sent with the box's profile. |
| `timezone` | `UTC` | Portal cookie `timezone`, e.g. `Europe/Paris`. |
> **These are the untested part of this plugin.** Every portal it has run
@@ -204,11 +204,20 @@ http://portal.example.com/c/ | 00:1A:79:AA:BB:CC | model=MAG322 timezone=Europe/
> gets it wrong, that is a bug worth reporting with the values your provider
> gave you.
-Which authentication runs is decided by one thing — whether you supplied
-credentials. With a username *and* password: handshake, then `do_auth`. Without:
-handshake, then a device-ID step. A failed device-ID step is **not fatal**, as
-plenty of portals authorise on the MAC alone; if the session really is
-unauthorised, the channel fetch says so straight after, and far more clearly.
+**The portal decides which authentication runs**, not the settings. Distalker
+shakes hands, presents the box's profile, and does what the answer asks for:
+nothing more if the portal is satisfied, or `do_auth` with your username and
+password if it says it wants them. If it refuses the account outright, the
+message you get is the provider's own — "subscription expired", "blocked" —
+rather than a guess made here.
+
+So credentials on a portal line are there for the portals that ask; a portal
+that never asks ignores them. Two consequences worth knowing:
+
+- A portal that asks for credentials **and has none on its line** now fails the
+ sync, saying so. It used to fetch an empty channel list and blame the MAC.
+- A portal that has no profile endpoint at all — some do not — still works, with
+ a warning in the log, on the strength of the MAC alone.
### Other settings
diff --git a/plugin.py b/plugin.py
index 49ef3de..64acb0b 100644
--- a/plugin.py
+++ b/plugin.py
@@ -484,7 +484,7 @@ def _action_test_portals(self, params, settings, logger) -> Dict[str, Any]:
# must not cost a download of everything the user already has.
LINEUP_KEYS = (
"url", "mac", "username", "password", "device_id", "device_id2",
- "serial_number", "model", "timezone", "device_id_auth",
+ "serial_number", "model", "timezone", "signature",
)
def _plan(self, portals: List[PortalConfig]) -> Dict[str, Any]:
diff --git a/resolver.py b/resolver.py
index ce72312..b029855 100644
--- a/resolver.py
+++ b/resolver.py
@@ -69,10 +69,15 @@ def resolve(slug: str, cmd: str) -> tuple[str, stalker_api.PortalConfig]:
if cached:
# Optimistic path: reuse the cached token and skip the handshake.
+ #
+ # Only an actual refusal is worth a second attempt. A portal that is
+ # unreachable or answering with rubbish will do the same during the
+ # handshake, and a tune that spends two round-trips discovering that is
+ # a tune Dispatcharr spends not failing over to the next source.
try:
link = portal.create_link(cmd)
return link, cfg
- except PortalError as exc:
+ except stalker_api.PortalAuthError as exc:
log(f"cached session rejected ({exc}); re-authenticating")
stalker_api.clear_cached_token(slug, client)
portal = stalker_api.Portal(cfg)
diff --git a/stalker_api.py b/stalker_api.py
index a719cad..4626b2e 100644
--- a/stalker_api.py
+++ b/stalker_api.py
@@ -46,6 +46,30 @@
DEFAULT_SIGNATURE = "f" * 64
DEFAULT_TIMEZONE = "UTC"
+# The rest of what a MAG box tells get_profile about itself. Fixed rather than
+# configurable: these describe a firmware image, not an account, and a portal
+# that cared would want them to agree with each other -- which they only do as
+# the block libstalkerclient has been sending since 2015 (lib/libstalkerclient/
+# stb.c, `sc_stb_get_profile_defaults`). They describe a MAG250 image even when
+# stb_type says MAG254; no portal has ever been seen to cross-check the two,
+# and every Stalker client in the wild sends this same mismatch.
+STB_VERSION = (
+ "ImageDescription: 0.2.16-250; "
+ "ImageDate: 18 Mar 2013 19:56:53 GMT+0200; "
+ "PORTAL version: 4.9.9; "
+ "API Version: JS API version: 328; "
+ "STB API version: 134; "
+ "Player Engine version: 0x566"
+)
+STB_IMAGE_VERSION = 216
+STB_HW_VERSION = "1.7-BD-00"
+STB_NUM_BANKS = 1
+
+# What a portal answers with, in plain text and with no JSON around it, once
+# the token it was given is no longer good. Matched exactly because it is a
+# fixed string in Ministra rather than something a reseller writes.
+AUTH_FAILED_BODY = "authorization failed."
+
# Seconds to wait on any single portal request. Generous by HTTP standards
# because get_all_channels is one request for the entire line-up, and a busy
# portal can take minutes to assemble it.
@@ -148,6 +172,17 @@ class PortalError(Exception):
"""Raised when the portal rejects us or answers with nonsense."""
+class PortalAuthError(PortalError):
+ """The portal understood us and refused the session.
+
+ Separated from its parent because the two want opposite handling: a
+ transport failure is worth retrying, an account the portal has declined is
+ not, and only the second is worth repeating verbatim to the user -- the
+ portal's own wording ("blocked", "subscription expired") says more than
+ anything this plugin could infer.
+ """
+
+
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@@ -168,11 +203,7 @@ class PortalConfig:
serial_number: str = DEFAULT_SERIAL
model: str = DEFAULT_MODEL
timezone: str = DEFAULT_TIMEZONE
- # Accepted for parity with stalkerhek's profile settings. stalkerhek only
- # ever sends it from its STB proxy mode (proxy/proxy.go); none of the
- # handshake / auth / create_link requests this plugin makes include it.
signature: str = DEFAULT_SIGNATURE
- device_id_auth: bool = False
max_streams: int = 1
ffmpeg_args: str = DEFAULT_FFMPEG_ARGS
# Travels to Redis with the rest, so the resolver waits as long as the sync
@@ -443,12 +474,6 @@ def resolve(key: str, fallback: str) -> Tuple[str, bool]:
timezone=timezone,
signature=signature,
max_streams=max_streams,
- # Absence of credentials selects device-ID auth, exactly as
- # stalkerhek does (`deviceIdAuth := pd.Username == "" &&
- # pd.Password == ""` in webui/profiles.go). The default ffff...
- # IDs are what a MAC-only portal expects, so this fires even when
- # the user supplied no IDs of their own.
- device_id_auth=not (username and password),
)
portals.append(cfg)
@@ -921,6 +946,17 @@ def __init__(self, cfg: PortalConfig, token: str = "", timeout: Optional[int] =
self.session = requests.Session()
# Non-fatal notes from login(), for the caller to surface.
self.warnings: List[str] = []
+ # Whether the portal said the token it handed back is already good for
+ # more than the handshake. Reported straight back to it in get_profile.
+ self.valid_token = False
+ # What get_profile answered during login(), kept so nothing has to ask
+ # twice: the expiry report and the blocked flag both read it.
+ self.profile: Dict[str, Any] = {}
+ # Which flow login() ended up taking, for the "Test portals" report.
+ # Worth saying out loud: it is the portal's choice, not the user's, so
+ # it is the one thing that tells them whether the credentials they
+ # typed in are being used at all.
+ self.auth_method = "handshake only"
# -- plumbing ---------------------------------------------------------
@@ -958,6 +994,11 @@ def _get_json(self, query: str, with_auth: bool = True) -> Any:
except requests.RequestException as exc:
raise PortalError(f"request to portal failed: {exc}") from exc
+ if resp.status_code in (401, 403):
+ raise PortalAuthError(
+ f"portal refused the session (HTTP {resp.status_code})"
+ )
+
if resp.status_code < 200 or resp.status_code >= 300:
snippet = (resp.text or "").strip()[:300]
raise PortalError(
@@ -969,6 +1010,11 @@ def _get_json(self, query: str, with_auth: bool = True) -> Any:
return resp.json()
except ValueError:
snippet = (resp.text or "").strip()[:300]
+ # A dead session is answered in plain text with a 200 attached, so
+ # it arrives here rather than as an HTTP error. Saying so is what
+ # lets the resolver re-authenticate instead of failing the tune.
+ if snippet.lower() == AUTH_FAILED_BODY:
+ raise PortalAuthError("portal says the session is no longer authorised")
raise PortalError(f"portal returned non-JSON response: {snippet}")
# -- authentication ---------------------------------------------------
@@ -982,12 +1028,22 @@ def handshake(self) -> str:
js = data.get("js") if isinstance(data, dict) else None
if isinstance(js, dict) and js.get("token"):
self.token = str(js["token"])
+ # 'not_valid' is the portal saying the token still has to be
+ # earned. get_profile is told the same thing back, which is how it
+ # knows whether it is being asked to validate or merely to report.
+ self.valid_token = str(js.get("not_valid") or "0") in ("0", "")
if not self.token:
raise PortalError("handshake did not yield a token")
return self.token
def authenticate(self) -> None:
- """Associate credentials with the token (username/password portals)."""
+ """Associate credentials with the token. Run when the profile says 2.
+
+ Sent as a POST, where every other call here is a GET: pvr.stalker puts
+ the login and password in the query string, and there is no reason for
+ this plugin to write a subscriber's password into a proxy's access log
+ when the portal accepts a form body just as happily.
+ """
form = {
"type": "stb",
"action": "do_auth",
@@ -1012,59 +1068,146 @@ def authenticate(self) -> None:
if not payload.get("js"):
raise PortalError(payload.get("text") or "invalid credentials")
- def authenticate_with_device_ids(self) -> None:
- """Second-step auth for portals keyed on device IDs rather than login."""
- data = self._get_json(
- "type=stb&action=get_profile&JsHttpRequest=1-xml&hd=1"
- f"&sn={self.cfg.serial_number}&stb_type={self.cfg.model}"
- f"&device_id={self.cfg.device_id}&device_id2={self.cfg.device_id2}"
- "&auth_second_step=1"
+ def get_profile(self, auth_second_step: bool = False) -> Dict[str, Any]:
+ """Present the box to the portal and read back what it makes of it.
+
+ This is the request that carries the whole STB identity, and the reply
+ is the portal stating what it wants next -- see :meth:`login`. The
+ field list is libstalkerclient's, unchanged: portals have been known to
+ reject a profile that arrives with fields missing, and the cost of
+ sending all of them is a longer query string.
+ """
+ query = (
+ "type=stb&action=get_profile&JsHttpRequest=1-xml"
+ f"&hd=1&num_banks={STB_NUM_BANKS}"
+ f"&image_version={STB_IMAGE_VERSION}&hw_version={quote(STB_HW_VERSION)}"
+ f"&ver={quote(STB_VERSION)}"
+ f"&stb_type={quote(self.cfg.model)}&sn={quote(self.cfg.serial_number)}"
+ f"&device_id={quote(self.cfg.device_id)}"
+ f"&device_id2={quote(self.cfg.device_id2)}"
+ f"&signature={quote(self.cfg.signature)}"
+ f"¬_valid_token={0 if self.valid_token else 1}"
+ f"&auth_second_step={1 if auth_second_step else 0}"
)
+ data = self._get_json(query)
js = data.get("js") if isinstance(data, dict) else None
- if not isinstance(js, dict) or not js.get("id"):
- raise PortalError(
- (data or {}).get("text") or "device ID authentication rejected"
- )
+ return js if isinstance(js, dict) else {}
+
+ # What get_profile's 'status' means. The portal decides which authentication
+ # this account needs and says so here, rather than the client guessing from
+ # whether a password happens to be configured.
+ _PROFILE_OK = 0
+ _PROFILE_NEEDS_AUTH = 2
def login(self) -> str:
- """Handshake plus whichever auth flow this portal needs.
-
- Credentials win when present. Otherwise the device-ID step runs, which
- is what stalkerhek does for MAC-only portals -- but its failure is not
- fatal here: plenty of portals authorise purely on the MAC cookie and
- either lack ``get_profile`` or answer it without a profile id. If the
- session really is unauthorised, the very next call fails with a far
- more useful message than "device ID authentication rejected".
+ """Handshake, then whichever authentication the portal asks for.
+
+ The portal is the one that knows::
+
+ handshake -> token (+ 'not_valid': is it good for anything yet?)
+ get_profile
+ status 0 -> done
+ status 2 -> do_auth, then get_profile(auth_second_step=1)
+ anything -> refused; 'block_msg'/'msg' says why
+
+ This replaces guessing the flow from whether credentials were typed in.
+ The old guess was wrong in both directions -- it ran a device-ID step
+ against portals that wanted a password, and it had no way to tell a
+ blocked account from an empty line-up.
+
+ Two deliberate departures from that state machine, both of them
+ tolerance for portals that are not really Ministra:
+
+ * a reply with no ``status`` at all counts as 0. Ministra always sends
+ one; the clones this plugin mostly meets often do not, and the
+ previous version happily served them. pvr.stalker treats the same
+ silence as a failure, which would break every one of those installs.
+ * ``get_profile`` failing to answer *at all* -- 404, a gateway error,
+ prose instead of JSON -- is a warning, not an error. Plenty of
+ portals authorise on the MAC cookie alone and never implement it. An
+ explicit refusal (:class:`PortalAuthError`) is still fatal, because
+ that is the portal answering rather than failing to.
"""
self.handshake()
- if self.cfg.username and self.cfg.password:
- self.authenticate()
- elif self.cfg.device_id_auth:
- try:
- self.authenticate_with_device_ids()
- except PortalError as exc:
- self.warnings.append(
- f"device-ID authentication did not succeed ({exc}); "
- "continuing with MAC-only authorisation"
+ try:
+ self.profile = self.get_profile()
+ except PortalAuthError:
+ raise
+ except PortalError as exc:
+ self.warnings.append(
+ f"the portal did not answer get_profile ({exc}); "
+ "continuing with MAC-only authorisation"
+ )
+ return self.token
+
+ status = self._profile_status(self.profile)
+ self.auth_method = "profile"
+
+ if status == self._PROFILE_NEEDS_AUTH:
+ if not (self.cfg.username and self.cfg.password):
+ raise PortalAuthError(
+ self._profile_message(self.profile)
+ or "this portal wants a username and password; add "
+ "'username=... password=...' to its line"
)
+ self.authenticate()
+ self.profile = self.get_profile(auth_second_step=True)
+ status = self._profile_status(self.profile)
+ self.auth_method = "credentials"
+
+ if status != self._PROFILE_OK:
+ raise PortalAuthError(
+ self._profile_message(self.profile)
+ or f"portal refused the session (status {status})"
+ )
return self.token
+ @staticmethod
+ def _profile_status(profile: Dict[str, Any]) -> int:
+ """``status`` as an int. Absent, blank or unparseable all mean OK."""
+ raw = profile.get("status")
+ if raw is None or raw == "":
+ return Portal._PROFILE_OK
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return Portal._PROFILE_OK
+
+ @staticmethod
+ def _profile_message(profile: Dict[str, Any]) -> str:
+ """The portal's own explanation, if it gave one.
+
+ ``block_msg`` first: when both are set it is the specific one, and it
+ is what the reseller wrote for exactly this situation.
+ """
+ for key in ("block_msg", "msg"):
+ value = str(profile.get(key) or "").strip()
+ if value:
+ return value
+ return ""
+
def account_snapshot(self) -> Dict[str, Any]:
"""What the portal will say about the subscription itself.
- Two calls, both made once per sync and never at tune time. Neither is
- required for anything to work, so this returns what it managed to read
- and never raises.
+ One call, made once per sync and never at tune time. Not required for
+ anything to work, so this returns what it managed to read and never
+ raises.
``get_main_info`` is where resellers put the expiry date: Ministra shows
the ``phone`` field in the MAG interface, so that is the field they fill
in with it -- observed on every portal tested, in the form
- "August 18, 2027, 4:53 pm". ``get_profile`` carries ``blocked``, which
- turns "nothing plays and I do not know why" into one line of the report.
+ "August 18, 2027, 4:53 pm".
+
+ ``blocked`` comes from the profile :meth:`login` already read, rather
+ than from a second ``get_profile``. It is nearly always redundant now --
+ a blocked account normally answers with a non-zero ``status``, which
+ login refuses outright -- but portals that set the flag and leave the
+ status at 0 exist, and for those it is still the only warning anyone
+ gets.
- No connection limit is available from either: neither ``max_online`` nor
+ No connection limit is available anywhere: neither ``max_online`` nor
an equivalent exists in the responses, and ``playback_limit`` is a
portal-wide Ministra default (3 on unrelated providers, next to
``tv_playback_retry_limit`` = 3), not this account's allowance. Guessing
@@ -1081,14 +1224,7 @@ def account_snapshot(self) -> Dict[str, Any]:
except Exception:
pass
- try:
- js = self._get_json(
- "type=stb&action=get_profile&hd=1&JsHttpRequest=1-xml"
- ).get("js")
- if isinstance(js, dict):
- snapshot["blocked"] = str(js.get("blocked") or "0") not in ("0", "")
- except Exception:
- pass
+ snapshot["blocked"] = str(self.profile.get("blocked") or "0") not in ("0", "")
return snapshot
@@ -1160,12 +1296,21 @@ def create_link(self, cmd: str) -> str:
f"action=create_link&type=itv&cmd={quote(cmd, safe='')}&JsHttpRequest=1-xml"
)
js = data.get("js") if isinstance(data, dict) else None
+ # Typed as auth failures, both of them, because that is overwhelmingly
+ # what they are: a portal whose token has expired usually answers
+ # create_link with a hollow success -- 'js' false, or a 'cmd' that is
+ # empty -- rather than with the plain-text refusal. The resolver only
+ # re-authenticates on this class, so mistyping these would leave the
+ # cached-token path unable to recover from the very thing it exists
+ # for. The cost of being wrong is one handshake.
if not isinstance(js, dict):
- raise PortalError("create_link returned no data (session may have expired)")
+ raise PortalAuthError(
+ "create_link returned no data (session may have expired)"
+ )
raw = str(js.get("cmd") or "").strip()
if not raw:
- raise PortalError("create_link returned an empty command")
+ raise PortalAuthError("create_link returned an empty command")
link = extract_link(raw)
if not link:
diff --git a/sync.py b/sync.py
index 8e69ae4..052899a 100644
--- a/sync.py
+++ b/sync.py
@@ -492,11 +492,10 @@ def test_portal(cfg: PortalConfig) -> Dict[str, Any]:
return {
"portal": cfg.name,
"url": cfg.url,
- "auth": (
- "credentials"
- if (cfg.username and cfg.password)
- else ("device-id" if cfg.device_id_auth else "handshake only")
- ),
+ # What the portal actually asked for, not what was configured: a line
+ # carrying credentials against a portal that never requests them is
+ # reported as the handshake-only portal it is.
+ "auth": portal.auth_method,
"groups": len(genres),
"warnings": portal.warnings,
}
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..e884cea
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,265 @@
+"""Which authentication a portal gets, and who decides.
+
+The portal decides. ``get_profile`` answers with a ``status`` that says whether
+the session is good (0), needs credentials presented first (2), or is refused
+(anything else) -- and the refusal carries the provider's own wording, which is
+the only part of it worth showing a user.
+
+What these tests mostly pin down is the tolerance around that machine, because
+that is where a strict reading breaks real installs: most portals this plugin
+meets are not Ministra, and they answer with less than Ministra would.
+"""
+import os
+import sys
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, REPO)
+
+import stalker_api as s # noqa: E402
+
+
+def portal(replies, **cfg_kwargs):
+ """A Portal whose HTTP layer is a script of canned answers.
+
+ ``replies`` maps an action to a payload, or to a callable taking the query
+ string, or to an exception instance to raise. Every request made is
+ recorded on ``portal.queries``.
+ """
+ cfg = s.PortalConfig(
+ slug="t", name="T", url="http://p.example/c/portal.php",
+ mac="00:1A:79:AA:BB:CC", **cfg_kwargs
+ )
+ p = s.Portal(cfg)
+ p.queries = []
+
+ def fake_get_json(query, with_auth=True):
+ p.queries.append(query)
+ action = ""
+ for field in query.split("&"):
+ if field.startswith("action="):
+ action = field.split("=", 1)[1]
+ reply = replies.get(action, {"js": {}})
+ if isinstance(reply, Exception):
+ raise reply
+ if callable(reply):
+ return reply(query)
+ return reply
+
+ p._get_json = fake_get_json
+ p.authenticate = lambda: p.queries.append("POST action=do_auth")
+ return p
+
+
+HANDSHAKE = {"js": {"token": "TOK", "not_valid": 1}}
+
+
+def test_status_zero_is_authenticated():
+ p = portal({"handshake": HANDSHAKE, "get_profile": {"js": {"status": 0, "id": 7}}})
+ assert p.login() == "TOK"
+ assert p.auth_method == "profile"
+ assert not p.warnings
+
+
+def test_a_profile_without_a_status_is_taken_as_fine():
+ """The clone case, and the one a strict reading would break.
+
+ pvr.stalker treats a missing status as a failure. Portals that answer with
+ a bare profile -- no status anywhere -- are common, they worked before this
+ machine existed, and they must keep working.
+ """
+ p = portal({"handshake": HANDSHAKE, "get_profile": {"js": {"id": 42, "fname": "x"}}})
+ assert p.login() == "TOK"
+ assert not p.warnings
+
+
+def test_status_two_runs_do_auth_then_the_second_step():
+ seen = []
+
+ def profile(query):
+ second = "auth_second_step=1" in query
+ seen.append(second)
+ return {"js": {"status": 0 if second else 2}}
+
+ p = portal(
+ {"handshake": HANDSHAKE, "get_profile": profile},
+ username="joe", password="pw",
+ )
+ p.login()
+ assert seen == [False, True], seen
+ assert "POST action=do_auth" in p.queries
+ assert p.auth_method == "credentials"
+
+
+def test_status_two_without_credentials_says_what_to_add():
+ """The case the old guess could not see at all.
+
+ Without credentials on the line the previous version ran a device-ID step,
+ got a shrug, and carried on to fetch an empty channel list. The user was
+ then told to check their MAC address, which was not the problem.
+ """
+ p = portal({"handshake": HANDSHAKE, "get_profile": {"js": {"status": 2}}})
+ try:
+ p.login()
+ except s.PortalAuthError as exc:
+ assert "username" in str(exc) and "password" in str(exc), exc
+ else:
+ raise AssertionError("a portal asking for credentials must not be ignored")
+
+
+def test_the_portal_gets_the_last_word_on_why():
+ p = portal({
+ "handshake": HANDSHAKE,
+ "get_profile": {"js": {"status": 1, "msg": "generic",
+ "block_msg": "Subscription expired on 12/06"}},
+ })
+ try:
+ p.login()
+ except s.PortalAuthError as exc:
+ # block_msg beats msg: it is the specific one, written for this case.
+ assert str(exc) == "Subscription expired on 12/06", exc
+ else:
+ raise AssertionError("status 1 must refuse the session")
+
+
+def test_a_second_step_that_still_fails_is_refused():
+ p = portal(
+ {"handshake": HANDSHAKE,
+ "get_profile": {"js": {"status": 2, "msg": "bad credentials"}}},
+ username="joe", password="wrong",
+ )
+ try:
+ p.login()
+ except s.PortalAuthError as exc:
+ assert "bad credentials" in str(exc), exc
+ else:
+ raise AssertionError("credentials the portal keeps rejecting must raise")
+
+
+def test_a_portal_with_no_get_profile_still_logs_in():
+ """MAC-only portals that never implemented it, warned about but served."""
+ p = portal({
+ "handshake": HANDSHAKE,
+ "get_profile": s.PortalError("portal returned HTTP 404"),
+ })
+ assert p.login() == "TOK"
+ assert p.auth_method == "handshake only"
+ assert any("404" in w for w in p.warnings), p.warnings
+
+
+def test_an_explicit_refusal_is_never_downgraded_to_a_warning():
+ """The difference between 'did not answer' and 'answered no'."""
+ p = portal({
+ "handshake": HANDSHAKE,
+ "get_profile": s.PortalAuthError("portal refused the session (HTTP 403)"),
+ })
+ try:
+ p.login()
+ except s.PortalAuthError:
+ pass
+ else:
+ raise AssertionError("a refusal must not be swallowed as a warning")
+
+
+def test_not_valid_travels_back_as_not_valid_token():
+ p = portal({"handshake": HANDSHAKE, "get_profile": {"js": {"status": 0}}})
+ p.login()
+ profile_query = [q for q in p.queries if "action=get_profile" in q][0]
+ assert "not_valid_token=1" in profile_query, profile_query
+
+ p = portal({"handshake": {"js": {"token": "TOK", "not_valid": 0}},
+ "get_profile": {"js": {"status": 0}}})
+ p.login()
+ profile_query = [q for q in p.queries if "action=get_profile" in q][0]
+ assert "not_valid_token=0" in profile_query, profile_query
+
+
+def test_the_whole_stb_identity_is_sent():
+ """Every field libstalkerclient sends, signature included.
+
+ signature was a documented setting that no request ever carried, so a user
+ who set it was configuring nothing.
+ """
+ p = portal({"handshake": HANDSHAKE, "get_profile": {"js": {"status": 0}}},
+ signature="a" * 64, serial_number="SN1", model="MAG322")
+ p.login()
+ query = [q for q in p.queries if "action=get_profile" in q][0]
+ for expected in ("signature=" + "a" * 64, "sn=SN1", "stb_type=MAG322",
+ "num_banks=1", "image_version=216", "hd=1", "ver=", "hw_version="):
+ assert expected in query, f"{expected} missing from {query}"
+
+
+def test_a_dead_session_in_plain_text_is_an_auth_error():
+ """Ministra answers 200 with prose, not JSON, once a token has expired.
+
+ Typed, because the resolver's cached-token path re-authenticates on it,
+ and because the retry work still to come must not retry it.
+ """
+ import json as _json
+
+ class FakeResponse:
+ status_code = 200
+ text = "Authorization failed."
+
+ def json(self):
+ raise _json.JSONDecodeError("no", "Authorization failed.", 0)
+
+ cfg = s.PortalConfig(slug="t", name="T", url="http://p.example/c/portal.php",
+ mac="00:1A:79:AA:BB:CC")
+ p = s.Portal(cfg)
+ p.session.get = lambda *a, **k: FakeResponse()
+ try:
+ p._get_json("action=get_all_channels")
+ except s.PortalAuthError as exc:
+ assert "no longer authorised" in str(exc), exc
+ else:
+ raise AssertionError("'Authorization failed.' must be typed as an auth error")
+
+
+def test_create_link_expiry_is_typed_so_the_resolver_can_recover():
+ """A dead token is usually a hollow success, not a refusal.
+
+ The resolver's optimistic path re-authenticates on PortalAuthError alone,
+ so these two have to carry that type or the cached-token path could never
+ recover from the one thing it exists to survive.
+ """
+ p = portal({})
+ for payload in ({"js": False}, {"js": {"cmd": ""}}, {"js": {"cmd": " "}}):
+ p._get_json = lambda q, with_auth=True, _p=payload: _p
+ try:
+ p.create_link("ffmpeg http://x/1")
+ except s.PortalAuthError:
+ pass
+ else:
+ raise AssertionError(f"{payload} must be an auth error")
+
+
+def test_a_reply_that_is_simply_not_a_link_is_not_an_auth_error():
+ """Re-authenticating cannot turn prose into a URL, so it must not try."""
+ p = portal({})
+ p._get_json = lambda q, with_auth=True: {"js": {"cmd": "no link here"}}
+ try:
+ p.create_link("x")
+ except s.PortalAuthError:
+ raise AssertionError("an unusable command must not trigger a re-login")
+ except s.PortalError:
+ pass
+
+
+def test_an_auth_error_is_still_a_portal_error():
+ """Callers that only catch PortalError must not start leaking exceptions."""
+ assert issubclass(s.PortalAuthError, s.PortalError)
+
+
+if __name__ == "__main__":
+ failures = 0
+ for name, fn in sorted(globals().items()):
+ if not name.startswith("test_") or not callable(fn):
+ continue
+ try:
+ fn()
+ print(f"PASS {name}")
+ except Exception as exc:
+ failures += 1
+ print(f"FAIL {name}: {type(exc).__name__}: {exc}")
+ print("\n" + ("ALL AUTH TESTS PASSED" if not failures else f"{failures} FAILURE(S)"))
+ sys.exit(1 if failures else 0)
diff --git a/tests/test_config.py b/tests/test_config.py
index 85de62c..11f625b 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -15,10 +15,6 @@ def test_builtin_defaults():
assert portal.timezone == s.DEFAULT_TIMEZONE
assert portal.device_id == s.DEFAULT_DEVICE_ID
assert portal.signature == s.DEFAULT_SIGNATURE
- # No credentials selects device-ID auth even with the default ffff... IDs,
- # matching stalkerhek's `deviceIdAuth := Username == "" && Password == ""`.
- # This is the plain URL+MAC case, so it must not regress.
- assert portal.device_id_auth is True
def test_stb_identity_is_per_portal():
@@ -44,13 +40,12 @@ def test_stb_identity_is_per_portal():
assert plain.device_id == s.DEFAULT_DEVICE_ID
-def test_credentials_beat_device_id_auth():
+def test_credentials_are_read_off_the_line():
(portal,), _ = s.parse_portals(
"U | http://c.example/c/ | 00:1A:79:AA:BB:02 | username=joe password=pw device_id="
+ "A" * 64
)
assert portal.username == "joe" and portal.password == "pw"
- assert portal.device_id_auth is False
def test_extras_split_by_space_and_pipe_and_quotes():
@@ -116,15 +111,18 @@ def test_url_only_and_mac_only_config():
assert not errors
assert portal.url == "http://somedomain.com:8080/c/portal.php"
assert portal.mac == "00:1A:79:AA:BB:CC"
- assert portal.device_id_auth is True
assert portal.username == "" and portal.password == ""
assert portal.max_streams == 1
-def test_partial_credentials_fall_back_to_device_auth():
- """A username with no password is not usable credentials."""
+def test_partial_credentials_are_kept_as_written():
+ """A username with no password is not usable credentials.
+
+ Kept rather than rejected: login() only reaches for them when the portal
+ asks (profile status 2), and it is the one that decides they are missing.
+ """
(portal,), _ = s.parse_portals("A | http://a.example/c/ | 00:1A:79:AA:BB:01 | username=joe")
- assert portal.device_id_auth is True
+ assert portal.username == "joe" and portal.password == ""
def test_pseudo_url_roundtrip():
diff --git a/tests/test_mock_portal.py b/tests/test_mock_portal.py
index a71a8ba..c1c92a1 100644
--- a/tests/test_mock_portal.py
+++ b/tests/test_mock_portal.py
@@ -32,14 +32,19 @@ def _reply(self, payload):
def _handle(self, params, method):
action = params.get("action", [""])[0]
- seen_requests.append((method, action, dict(self.headers)))
+ seen_requests.append((method, action, dict(self.headers), params))
if action == "handshake":
- return self._reply({"js": {"token": "TESTTOKEN123", "not_valid": 0}})
+ return self._reply({"js": {"token": "TESTTOKEN123", "not_valid": 1}})
if action == "do_auth":
return self._reply({"js": True, "text": "authenticated"})
if action == "get_profile":
- return self._reply({"js": {"id": 42, "fname": "Test User"}})
+ # A portal that wants credentials: status 2 until do_auth has run
+ # and get_profile comes back with auth_second_step=1. This is the
+ # full state machine login() implements.
+ if params.get("auth_second_step", ["0"])[0] == "1":
+ return self._reply({"js": {"id": 42, "fname": "Test User", "status": 0}})
+ return self._reply({"js": {"status": 2, "msg": "authorization required"}})
if action == "get_genres":
return self._reply({"js": [
{"id": "1", "title": "FR| SPORT"},
@@ -138,13 +143,32 @@ def main():
print("pseudo-URL round-trip through the playlist: OK")
# Auth header must be present on content calls but absent on handshake.
- by_action = {a: h for _, a, h in seen_requests}
+ by_action = {a: h for _, a, h, _ in seen_requests}
assert "Authorization" not in by_action["handshake"], "handshake must not send a token"
assert by_action["get_all_channels"]["Authorization"] == "Bearer TESTTOKEN123"
assert "MAG200 stbapp" in by_action["get_all_channels"]["User-Agent"]
assert "mac=00%3A1A%3A79%3AAA%3ABB%3ACC" in by_action["get_all_channels"]["Cookie"]
print("headers (UA / Bearer / MAC cookie): OK")
+ # The portal asked for credentials and got them, in the right order.
+ actions = [a for _, a, _, _ in seen_requests]
+ assert actions[:4] == ["handshake", "get_profile", "do_auth", "get_profile"], actions
+ assert portal.auth_method == "credentials", portal.auth_method
+
+ profiles = [p for _, a, _, p in seen_requests if a == "get_profile"]
+ # not_valid=1 from the handshake must come back as not_valid_token=1.
+ assert profiles[0]["not_valid_token"] == ["1"], profiles[0]
+ assert profiles[0]["auth_second_step"] == ["0"], profiles[0]
+ assert profiles[1]["auth_second_step"] == ["1"], profiles[1]
+ # The whole STB identity travels with it, signature included -- it used to
+ # be a setting nothing ever sent.
+ assert profiles[0]["signature"] == [s.DEFAULT_SIGNATURE], profiles[0]
+ assert profiles[0]["stb_type"] == [s.DEFAULT_MODEL], profiles[0]
+ assert profiles[0]["sn"] == [s.DEFAULT_SERIAL], profiles[0]
+ assert profiles[0]["hw_version"] == [s.STB_HW_VERSION], profiles[0]
+ assert "PORTAL version: 4.9.9" in profiles[0]["ver"][0], profiles[0]
+ print("get_profile state machine (status 2 -> do_auth -> second step): OK")
+
# ffmpeg argv construction, as resolver.py builds it.
import resolver
argv = resolver.build_ffmpeg_command(cfg, link)
From d469c436621f09237e301f75bf47a1f0522696f9 Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:30:13 +0200
Subject: [PATCH 02/20] Run the authentication tests in CI
Every other test file is a step of its own, so a failure names itself in the
run summary rather than hiding inside a neighbour.
---
.github/workflows/ci.yml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d7bbcc8..5dfa515 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,9 @@ jobs:
- name: Config and protocol tests
run: python3 tests/test_config.py
+ - name: Authentication state machine test
+ run: python3 tests/test_auth.py
+
- name: Mock portal integration test
run: python3 tests/test_mock_portal.py
From e4f114174cd6c58990d70ce6452046cace92964e Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:35:50 +0200
Subject: [PATCH 03/20] Send the identity both ways, and let a sync survive a
bad minute
Two changes with one cause: portals that are not Ministra.
The MAC goes in a cookie and the token in an Authorization header because
that is what a MAG box sends and what Ministra reads. It is not what every
portal reads. open-tv authenticates against real portals using only the
query parameters -- no cookie, no header -- so a portal that ignores ours
would be unusable here while working there. Sending both forms costs a
handful of characters per request, and nothing has ever been seen to object
to the one it does not want.
The retry is the same admission applied to time rather than to shape. A
dropped connection or a gateway error during a sync used to cost the user
their whole line-up until the next scheduled run, for a portal that was
merely busy. Three attempts, one then two then four seconds apart.
What it must not do is the part worth reviewing. Retries default to off and
the resolver leaves them there: a source that is not answering has to fail
now, or Dispatcharr never fails over to the one that would have worked --
the reasoning already spelled out in the ffmpeg arguments. "Test portals"
also leaves them off, because it runs on the request thread and three
attempts at a 60s timeout outlast the proxy in front of it. And only
failures another attempt could fix are repeated: a refusal is left alone,
since retrying a rejected login is how a MAC gets itself banned.
do_auth moves onto the same request path, which gains it the retry and
types a rejected password as PortalAuthError -- it was the one credential
failure still arriving as a bare PortalError.
---
CHANGELOG.md | 17 +++
CONTRIBUTING.md | 1 +
README.md | 2 +-
stalker_api.py | 97 ++++++++++++--
sync.py | 20 ++-
tests/test_auth.py | 2 +-
tests/test_mock_portal.py | 6 +
tests/test_transport.py | 270 ++++++++++++++++++++++++++++++++++++++
8 files changed, 398 insertions(+), 17 deletions(-)
create mode 100644 tests/test_transport.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87e20a4..da984ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,23 @@
- The `device_id_auth` line key is gone. Nothing needs to be changed: it was
never something to write on a portal line, only a value the plugin derived
for itself, and lines are re-read on every sync.
+- The MAC and the session token now travel in the query string as well as in
+ the cookie and the `Authorization` header. Portals read one form or the
+ other, and sending both costs nothing.
+
+**Syncing**
+
+- **A sync survives a portal having a bad minute.** Requests made while
+ syncing are attempted up to three times, one then two then four seconds
+ apart, where a single dropped connection or gateway error used to cost the
+ whole line-up until the next scheduled run. Only failures that another
+ attempt could fix are repeated: a refused login, a blocked account or a
+ missing endpoint still fails immediately.
+- Nothing is retried at tune time, deliberately. A source that is not answering
+ has to fail fast enough for Dispatcharr to move to the next one, which is the
+ same reason `-reconnect` is not in the default ffmpeg arguments. "Test
+ portals" does not retry either — it answers a click, and three attempts at
+ the portal timeout outlast the browser waiting for it.
## 0.9.2
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9297aac..3958a9c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -34,6 +34,7 @@ script with its own `__main__` block:
```bash
python3 tests/test_config.py # portal-line parsing, STB defaults, pseudo-URLs
python3 tests/test_auth.py # which authentication a portal asks for
+python3 tests/test_transport.py # what a request carries, and when it is retried
python3 tests/test_registry.py # surviving the settings panel
python3 tests/test_manifest.py # plugin.json vs plugin.py, and run()'s plumbing
python3 tests/test_fallback.py # non-portal sources on a Distalker channel
diff --git a/README.md b/README.md
index e7bf4dc..080ec75 100644
--- a/README.md
+++ b/README.md
@@ -227,7 +227,7 @@ You should not need any of these.
| --- | --- | --- |
| ffmpeg arguments | a plain remux, plus the MAG headers and `-rw_timeout` | Placeholders `{url}`, `{ua}`, `{referer}`, `{headers}`. Must write MPEG-TS to `pipe:1`. **Do not add `-reconnect`** — it retries a link that has already expired, and stops Dispatcharr failing over to the channel's other sources. |
| Fallback stream profile | `ffmpeg` | Plays the *other* sources on a Distalker channel — see below. |
-| Portal request timeout | `60` s | Every portal request, sync and tune alike. Raise it if a busy portal times out assembling its channel list. |
+| Portal request timeout | `60` s | Every portal request, sync and tune alike. Raise it if a busy portal times out assembling its channel list. A sync retries twice on top of this — 1 s then 2 s apart — so raising it far also lengthens the worst case of a failing sync. |
| Auto-assign stream profile | on | Gives a channel the Distalker profile as it gains a portal stream, after each M3U refresh, and once more after any channel fails to start. |
### Channels that mix a portal with another provider
diff --git a/stalker_api.py b/stalker_api.py
index 4626b2e..6ad38ce 100644
--- a/stalker_api.py
+++ b/stalker_api.py
@@ -26,6 +26,7 @@
import shutil
import sys
import tempfile
+import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
@@ -70,6 +71,23 @@
# fixed string in Ministra rather than something a reseller writes.
AUTH_FAILED_BODY = "authorization failed."
+# Answers worth asking again for. Everything else is the portal having made up
+# its mind: a 404 is not going to become a 200, and a 403 is the subject of
+# PortalAuthError, which must never be retried -- repeating a rejected login is
+# how a MAC gets itself banned.
+#
+# 500 is in the list and is the debatable one. Ministra returns it both for
+# "busy right now" and for some permanent failures, so a third of these retries
+# will be spent on something that cannot succeed. Three attempts is a small
+# enough bill for covering the transient half.
+RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
+
+# Seconds before the 1st, 2nd and 3rd retry. Fixed rather than jittered: the
+# calls that retry are made one portal at a time from a single process, so
+# there is no herd to spread out, and a deterministic delay is one a test can
+# assert on.
+RETRY_BACKOFF = (1.0, 2.0, 4.0)
+
# Seconds to wait on any single portal request. Generous by HTTP standards
# because get_all_channels is one request for the entire line-up, and a busy
# portal can take minutes to assemble it.
@@ -937,12 +955,26 @@ class Portal:
and skip the handshake.
"""
- def __init__(self, cfg: PortalConfig, token: str = "", timeout: Optional[int] = None):
+ def __init__(
+ self,
+ cfg: PortalConfig,
+ token: str = "",
+ timeout: Optional[int] = None,
+ retries: int = 0,
+ ):
self.cfg = cfg
self.token = token
# The portal's own setting unless a caller insists, so every request
# made about a portal honours what the user configured for it.
self.timeout = timeout or getattr(cfg, "timeout", None) or DEFAULT_TIMEOUT
+ # Retrying is the caller's decision, not this class's, and it defaults
+ # to off because the caller that matters most must not have it. At tune
+ # time a portal that is not answering has to fail *now*: the resolver's
+ # only job on a bad source is to exit non-zero fast enough that
+ # Dispatcharr moves to the next one -- the same reasoning that keeps
+ # ffmpeg's -reconnect out of the default arguments. A sync has the
+ # opposite need, and asks for retries explicitly.
+ self.retries = max(0, int(retries))
self.session = requests.Session()
# Non-fatal notes from login(), for the caller to surface.
self.warnings: List[str] = []
@@ -985,14 +1017,51 @@ def _headers(self, with_auth: bool = True) -> Dict[str, str]:
headers["Authorization"] = "Bearer " + self.token
return headers
+ def _common_params(self, with_auth: bool = True) -> str:
+ """Identity repeated in the query string, beside the cookie and header.
+
+ Belt and braces, and cheap. The MAC travels in a cookie and the token in
+ an Authorization header because that is what a MAG box does and what
+ Ministra reads -- but plenty of what this plugin meets are not Ministra,
+ and open-tv authenticates against real portals using *only* these two
+ query parameters, with no cookie and no header at all. Sending both
+ forms covers portals that read either, and no portal has been seen to
+ mind the one it ignores.
+ """
+ params = f"mac={quote(self.cfg.mac)}"
+ if with_auth and self.token:
+ params += f"&token={quote(self.token)}"
+ return params
+
+ def _request(self, method: str, url: str, **kwargs) -> requests.Response:
+ """One portal request, repeated only while repeating it could help.
+
+ Returns the response for the caller to interpret, including a final
+ failing one: deciding what an HTTP 404 means is :meth:`_get_json`'s job,
+ not this one's. Only exhausting the attempts without ever getting an
+ answer raises here.
+ """
+ last_error = ""
+ for attempt in range(self.retries + 1):
+ if attempt:
+ time.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF)) - 1])
+ try:
+ resp = self.session.request(
+ method, url, timeout=self.timeout, **kwargs
+ )
+ except requests.RequestException as exc:
+ last_error = f"request to portal failed: {exc}"
+ continue
+ if resp.status_code in RETRYABLE_STATUS and attempt < self.retries:
+ last_error = f"portal returned HTTP {resp.status_code}"
+ continue
+ return resp
+
+ raise PortalError(last_error or "request to portal failed")
+
def _get_json(self, query: str, with_auth: bool = True) -> Any:
- url = f"{self.cfg.url}?{query}"
- try:
- resp = self.session.get(
- url, headers=self._headers(with_auth), timeout=self.timeout
- )
- except requests.RequestException as exc:
- raise PortalError(f"request to portal failed: {exc}") from exc
+ url = f"{self.cfg.url}?{query}&{self._common_params(with_auth)}"
+ resp = self._request("GET", url, headers=self._headers(with_auth))
if resp.status_code in (401, 403):
raise PortalAuthError(
@@ -1055,18 +1124,18 @@ def authenticate(self) -> None:
}
headers = self._headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
+ url = f"{self.cfg.url}?{self._common_params()}"
+ resp = self._request("POST", url, data=form, headers=headers)
+
try:
- resp = self.session.post(
- self.cfg.url, data=form, headers=headers, timeout=self.timeout
- )
payload = resp.json()
- except requests.RequestException as exc:
- raise PortalError(f"authentication request failed: {exc}") from exc
except ValueError:
raise PortalError("authentication returned a non-JSON response")
if not payload.get("js"):
- raise PortalError(payload.get("text") or "invalid credentials")
+ # The portal read the credentials and said no. Not retryable, and
+ # not the same failure as never having reached it.
+ raise PortalAuthError(payload.get("text") or "invalid credentials")
def get_profile(self, auth_second_step: bool = False) -> Dict[str, Any]:
"""Present the box to the portal and read back what it makes of it.
diff --git a/sync.py b/sync.py
index 052899a..e46f526 100644
--- a/sync.py
+++ b/sync.py
@@ -407,9 +407,21 @@ def apply_stream_profile() -> Dict[str, int]:
# ---------------------------------------------------------------------------
+# Three attempts at anything a sync asks the portal for. A provider that
+# hiccups once should not cost the user their whole line-up until the next
+# scheduled run, and a sync has the time -- nothing is waiting on it.
+#
+# Worst case per call is (retries + 1) x timeout plus the backoff, so 187s at
+# the default 60s timeout. Portals are synced one after another under a lock
+# that expires after 1800s (see claim_sync_lock): raising either number far
+# enough that a run could outlive its own lock would let a second run start on
+# top of the first.
+SYNC_RETRIES = 2
+
+
def sync_portal(cfg: PortalConfig, logger, trigger_refresh: bool = True) -> Dict[str, Any]:
"""Full sync for one portal: log in, fetch, write M3U, refresh account."""
- portal = Portal(cfg)
+ portal = Portal(cfg, retries=SYNC_RETRIES)
portal.login()
for warning in portal.warnings:
logger.warning("distalker: %s: %s", cfg.name, warning)
@@ -485,6 +497,12 @@ def test_portal(cfg: PortalConfig) -> Dict[str, Any]:
and Dispatcharr comes back as a 504. Genres are a short list and prove the
same thing -- that the MAC authenticates and the session works. The channel
count comes from a sync, which no longer blocks a request.
+
+ Retries are off here for the same reason, and deliberately not shared with
+ :data:`SYNC_RETRIES`: three attempts at a 60-second timeout is three minutes
+ of a request thread, and the proxy in front of Dispatcharr gives up long
+ before that. A portal that needs a second attempt to answer is a portal
+ this action should report as unwell, not one it should wait out.
"""
portal = Portal(cfg)
portal.login()
diff --git a/tests/test_auth.py b/tests/test_auth.py
index e884cea..f24f883 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -206,7 +206,7 @@ def json(self):
cfg = s.PortalConfig(slug="t", name="T", url="http://p.example/c/portal.php",
mac="00:1A:79:AA:BB:CC")
p = s.Portal(cfg)
- p.session.get = lambda *a, **k: FakeResponse()
+ p.session.request = lambda *a, **k: FakeResponse()
try:
p._get_json("action=get_all_channels")
except s.PortalAuthError as exc:
diff --git a/tests/test_mock_portal.py b/tests/test_mock_portal.py
index c1c92a1..749336e 100644
--- a/tests/test_mock_portal.py
+++ b/tests/test_mock_portal.py
@@ -150,6 +150,12 @@ def main():
assert "mac=00%3A1A%3A79%3AAA%3ABB%3ACC" in by_action["get_all_channels"]["Cookie"]
print("headers (UA / Bearer / MAC cookie): OK")
+ # The identity reaches a real socket in both forms, not just in the
+ # headers a unit test can inspect.
+ content = [p for _, a, _, p in seen_requests if a == "get_all_channels"][0]
+ assert content["mac"] == ["00:1A:79:AA:BB:CC"], content
+ assert content["token"] == ["TESTTOKEN123"], content
+
# The portal asked for credentials and got them, in the right order.
actions = [a for _, a, _, _ in seen_requests]
assert actions[:4] == ["handshake", "get_profile", "do_auth", "get_profile"], actions
diff --git a/tests/test_transport.py b/tests/test_transport.py
new file mode 100644
index 0000000..59f5fe5
--- /dev/null
+++ b/tests/test_transport.py
@@ -0,0 +1,270 @@
+"""What a portal request carries, and when it is worth making twice.
+
+Two changes share this file because they are the same decision seen from two
+sides: what to do about a portal that does not behave like Ministra. One sends
+the identity in every form a portal might read it in; the other accepts that a
+portal can simply be having a bad minute.
+
+The retry half matters most for what it must *not* do. Retrying at tune time
+would keep a dead source alive long enough to stop Dispatcharr failing over to
+a working one, and retrying a refused login is how a MAC gets banned.
+"""
+import os
+import sys
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, REPO)
+
+import stalker_api as s # noqa: E402
+
+
+class FakeResponse:
+ def __init__(self, status=200, payload=None, text=""):
+ self.status_code = status
+ self._payload = payload
+ self.text = text
+
+ def json(self):
+ if self._payload is None:
+ raise ValueError("not json")
+ return self._payload
+
+
+class FakeSession:
+ """Replays a script of answers, recording every call and every sleep."""
+
+ def __init__(self, script):
+ self.script = list(script)
+ self.calls = []
+
+ def request(self, method, url, **kwargs):
+ self.calls.append((method, url, kwargs))
+ answer = self.script.pop(0) if self.script else FakeResponse()
+ if isinstance(answer, Exception):
+ raise answer
+ return answer
+
+
+def portal(script, retries=0, token="", **cfg_kwargs):
+ cfg = s.PortalConfig(
+ slug="t", name="T", url="http://p.example/c/portal.php",
+ mac="00:1A:79:AA:BB:CC", **cfg_kwargs
+ )
+ p = s.Portal(cfg, token=token, retries=retries)
+ p.session = FakeSession(script)
+ return p
+
+
+def no_sleeping():
+ """Swap time.sleep out, collecting what would have been waited.
+
+ Returns the list and the undo, so a backoff can be asserted on without the
+ test suite actually spending it.
+ """
+ slept = []
+ original = s.time.sleep
+ s.time.sleep = slept.append
+ return slept, (lambda: setattr(s.time, "sleep", original))
+
+
+# -- identity in the query string ---------------------------------------
+
+
+def test_the_mac_travels_in_the_query_as_well_as_the_cookie():
+ p = portal([FakeResponse(payload={"js": {}})])
+ p._get_json("action=get_genres")
+ _, url, kwargs = p.session.calls[0]
+ assert "mac=00%3A1A%3A79%3AAA%3ABB%3ACC" in url, url
+ assert "mac=00%3A1A%3A79%3AAA%3ABB%3ACC" in kwargs["headers"]["Cookie"]
+
+
+def test_the_token_travels_in_the_query_as_well_as_the_header():
+ p = portal([FakeResponse(payload={"js": {}})], token="TOK")
+ p._get_json("action=get_genres")
+ _, url, kwargs = p.session.calls[0]
+ assert "token=TOK" in url, url
+ assert kwargs["headers"]["Authorization"] == "Bearer TOK"
+
+
+def test_the_handshake_still_proves_nothing_it_has_not_earned():
+ """No token in either place before there is one to send."""
+ p = portal([FakeResponse(payload={"js": {"token": "NEW"}})])
+ p.handshake()
+ _, url, kwargs = p.session.calls[0]
+ assert "Authorization" not in kwargs["headers"]
+ # handshake sends its own empty 'token=' by protocol; what must not appear
+ # is a second, authenticating one appended by the common parameters.
+ assert url.count("token=") == 1, url
+ assert "mac=" in url, "the MAC is still needed to be recognised"
+
+
+def test_credentials_are_posted_with_the_identity_on_the_url():
+ p = portal([FakeResponse(payload={"js": True})], token="TOK",
+ username="joe", password="pw")
+ p.authenticate()
+ method, url, kwargs = p.session.calls[0]
+ assert method == "POST"
+ assert "mac=" in url and "token=TOK" in url, url
+ # The password stays in the body, where a proxy log will not keep it.
+ assert "pw" not in url
+ assert kwargs["data"]["password"] == "pw"
+
+
+# -- retrying -----------------------------------------------------------
+
+
+def test_nothing_is_retried_by_default():
+ """The resolver's setting, and the one that protects failover."""
+ import requests
+
+ p = portal([requests.ConnectionError("down"), FakeResponse(payload={"js": {}})])
+ try:
+ p._get_json("action=get_genres")
+ except s.PortalError:
+ pass
+ else:
+ raise AssertionError("a portal that is down must fail on the first try")
+ assert len(p.session.calls) == 1, p.session.calls
+
+
+def test_a_transient_failure_is_ridden_out_when_retries_are_asked_for():
+ import requests
+
+ slept, restore = no_sleeping()
+ try:
+ p = portal(
+ [requests.ConnectionError("down"),
+ FakeResponse(502, text="bad gateway"),
+ FakeResponse(payload={"js": {"ok": 1}})],
+ retries=2,
+ )
+ assert p._get_json("action=get_genres") == {"js": {"ok": 1}}
+ assert len(p.session.calls) == 3
+ assert slept == [1.0, 2.0], slept
+ finally:
+ restore()
+
+
+def test_the_attempts_do_run_out():
+ slept, restore = no_sleeping()
+ try:
+ p = portal([FakeResponse(503, text="busy")] * 3, retries=2)
+ try:
+ p._get_json("action=get_genres")
+ except s.PortalError as exc:
+ assert "503" in str(exc), exc
+ else:
+ raise AssertionError("a portal that never answers must raise")
+ assert len(p.session.calls) == 3
+ finally:
+ restore()
+
+
+def test_a_refusal_is_never_retried():
+ """Repeating a rejected login is how a MAC gets itself banned."""
+ slept, restore = no_sleeping()
+ try:
+ p = portal([FakeResponse(403, text="no")] * 3, retries=2)
+ try:
+ p._get_json("action=get_genres")
+ except s.PortalAuthError:
+ pass
+ else:
+ raise AssertionError("403 must be an auth error")
+ assert len(p.session.calls) == 1, p.session.calls
+ assert slept == []
+ finally:
+ restore()
+
+
+def test_a_verdict_is_never_retried():
+ """404 is the portal having made up its mind; asking again changes nothing."""
+ slept, restore = no_sleeping()
+ try:
+ p = portal([FakeResponse(404, text="gone")] * 3, retries=2)
+ try:
+ p._get_json("action=get_genres")
+ except s.PortalError as exc:
+ assert "404" in str(exc), exc
+ assert len(p.session.calls) == 1, p.session.calls
+ finally:
+ restore()
+
+
+def test_prose_instead_of_json_is_not_a_reason_to_ask_again():
+ """It arrives with a 200 attached, so only the body says anything is wrong.
+
+ Retrying would also delay the resolver's re-authentication, which is what
+ this particular body is supposed to trigger.
+ """
+ slept, restore = no_sleeping()
+ try:
+ p = portal([FakeResponse(200, text="Authorization failed.")] * 3, retries=2)
+ try:
+ p._get_json("action=get_genres")
+ except s.PortalAuthError:
+ pass
+ else:
+ raise AssertionError("expected the session to be reported as dead")
+ assert len(p.session.calls) == 1, p.session.calls
+ finally:
+ restore()
+
+
+def test_the_sync_asks_for_retries_and_the_test_action_does_not():
+ """The one asymmetry that matters, pinned so a refactor keeps it.
+
+ 'Test portals' runs on the request thread, where three attempts at a
+ 60-second timeout outlast any proxy in front of Dispatcharr.
+ """
+ import importlib.util
+ import types
+
+ pkg = types.ModuleType("distalker_probe")
+ pkg.__path__ = [REPO]
+ sys.modules["distalker_probe"] = pkg
+ spec = importlib.util.spec_from_file_location(
+ "distalker_probe.sync", os.path.join(REPO, "sync.py")
+ )
+ sync = importlib.util.module_from_spec(spec)
+ sys.modules["distalker_probe.sync"] = sync
+ spec.loader.exec_module(sync)
+
+ built = []
+ original = sync.Portal
+ sync.Portal = lambda cfg, **kw: built.append(kw) or original(cfg, **kw)
+ _, restore = no_sleeping() # sync_portal would otherwise back off for real
+ try:
+ cfg = s.PortalConfig(slug="t", name="T", url="http://p.example/c/portal.php",
+ mac="00:1A:79:AA:BB:CC")
+ for call in (lambda: sync.test_portal(cfg), lambda: sync.sync_portal(cfg, _Logger())):
+ try:
+ call()
+ except Exception:
+ pass # no portal is listening; only the construction matters
+ finally:
+ sync.Portal = original
+ restore()
+
+ assert built[0].get("retries", 0) == 0, f"test_portal must not retry: {built[0]}"
+ assert built[1].get("retries") == sync.SYNC_RETRIES, built[1]
+
+
+class _Logger:
+ def __getattr__(self, _name):
+ return lambda *a, **k: None
+
+
+if __name__ == "__main__":
+ failures = 0
+ for name, fn in sorted(globals().items()):
+ if not name.startswith("test_") or not callable(fn):
+ continue
+ try:
+ fn()
+ print(f"PASS {name}")
+ except Exception as exc:
+ failures += 1
+ print(f"FAIL {name}: {type(exc).__name__}: {exc}")
+ print("\n" + ("ALL TRANSPORT TESTS PASSED" if not failures else f"{failures} FAILURE(S)"))
+ sys.exit(1 if failures else 0)
From eb0afb98308980a21c9845958bb0e36ae708837b Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:35:58 +0200
Subject: [PATCH 04/20] Run the transport tests in CI
Same reason as the authentication step: a failure that names itself.
---
.github/workflows/ci.yml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5dfa515..380d528 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,6 +28,9 @@ jobs:
- name: Authentication state machine test
run: python3 tests/test_auth.py
+ - name: Request transport and retry test
+ run: python3 tests/test_transport.py
+
- name: Mock portal integration test
run: python3 tests/test_mock_portal.py
From 969eab822851f75cc33a57b106564208444cacd0 Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:38:25 +0200
Subject: [PATCH 05/20] Read the logos and the archive flags the portal
actually sends
Two logo shapes came out broken. A logo carrying any scheme other than
http(s) fell through the "must be a filename" branch and was glued behind
.../misc/logos/320/, producing a URL that pointed nowhere; and an inline
data: image got the same treatment, when the right answer is to drop it --
it is a valid logo, and a base64 payload has no business in the URL field
Dispatcharr stores this in. Both follow pvr.stalker's DetermineLogoURI.
Channels also carry enable_tv_archive and tv_archive_duration, which are now
read into ChannelEntry and deliberately not published. Dispatcharr does pick
those attributes up from an M3U and turns them into is_catchup/catchup_days,
but playing catch-up back is Xtream-only -- built from a server URL and
credentials a portal source has none of. Advertising it would put a catch-up
badge on channels whose catch-up cannot play. Captured now so that the day
that path stops being Xtream-shaped, the data is already arriving; the
reasoning is on ChannelEntry, where the next person will look.
Row parsing moves out of get_all_channels into _channel_from_row, which is
what the paginated listing still to come will need to share.
---
CHANGELOG.md | 5 ++
CONTRIBUTING.md | 1 +
stalker_api.py | 73 ++++++++++++++++------
sync.py | 4 ++
tests/test_listing.py | 141 ++++++++++++++++++++++++++++++++++++++++++
5 files changed, 206 insertions(+), 18 deletions(-)
create mode 100644 tests/test_listing.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da984ab..1519688 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,11 @@
**Syncing**
+- Channel logos survive two shapes that used to come out broken: a logo served
+ from another scheme than `http(s)` was treated as a filename and glued behind
+ the portal's logo path, and an inline `data:` image got the same treatment.
+ The first is now left alone, the second dropped — Dispatcharr keeps this in a
+ URL field, where a base64 payload does not belong.
- **A sync survives a portal having a bad minute.** Requests made while
syncing are attempted up to three times, one then two then four seconds
apart, where a single dropped connection or gateway error used to cost the
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3958a9c..1f72a5a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,6 +35,7 @@ script with its own `__main__` block:
python3 tests/test_config.py # portal-line parsing, STB defaults, pseudo-URLs
python3 tests/test_auth.py # which authentication a portal asks for
python3 tests/test_transport.py # what a request carries, and when it is retried
+python3 tests/test_listing.py # reading a portal's channel list
python3 tests/test_registry.py # surviving the settings panel
python3 tests/test_manifest.py # plugin.json vs plugin.py, and run()'s plumbing
python3 tests/test_fallback.py # non-portal sources on a Distalker channel
diff --git a/stalker_api.py b/stalker_api.py
index 6ad38ce..e7224d8 100644
--- a/stalker_api.py
+++ b/stalker_api.py
@@ -945,6 +945,17 @@ class ChannelEntry:
logo: str = ""
genre_id: str = ""
number: str = ""
+ # Catch-up, as the portal advertises it. Read but not yet published:
+ # Dispatcharr does pick these up from an M3U -- it turns 'tv_archive' and
+ # 'tv_archive_duration' attributes into a stream's is_catchup and
+ # catchup_days -- but playing one back is Xtream-only, built from a
+ # server URL and credentials this plugin's sources do not have
+ # (apps/timeshift/helpers.py). Emitting them would light up a catch-up
+ # badge on channels whose catch-up cannot play, which is worse than not
+ # offering it. Captured here so the day that path stops being
+ # Xtream-shaped, the data is already arriving.
+ tv_archive: bool = False
+ tv_archive_duration: str = ""
class Portal:
@@ -1335,24 +1346,36 @@ def get_all_channels(self) -> List[ChannelEntry]:
channels: List[ChannelEntry] = []
for row in rows:
- if not isinstance(row, dict):
- continue
- cmd = str(row.get("cmd") or "").strip()
- name = str(row.get("name") or "").strip()
- if not cmd or not name:
- continue
- channels.append(
- ChannelEntry(
- channel_id=str(row.get("id") or ""),
- name=name,
- cmd=cmd,
- logo=str(row.get("logo") or ""),
- genre_id=str(row.get("tv_genre_id") or ""),
- number=str(row.get("number") or ""),
- )
- )
+ channel = self._channel_from_row(row)
+ if channel is not None:
+ channels.append(channel)
return channels
+ @staticmethod
+ def _channel_from_row(row: Any) -> Optional[ChannelEntry]:
+ """One row of a channel listing, or None when it is not usable.
+
+ A row without a name or without a command is not a channel this plugin
+ can do anything with, and portals do emit them.
+ """
+ if not isinstance(row, dict):
+ return None
+ cmd = str(row.get("cmd") or "").strip()
+ name = str(row.get("name") or "").strip()
+ if not cmd or not name:
+ return None
+ return ChannelEntry(
+ channel_id=str(row.get("id") or ""),
+ name=name,
+ cmd=cmd,
+ logo=str(row.get("logo") or ""),
+ genre_id=str(row.get("tv_genre_id") or ""),
+ number=str(row.get("number") or ""),
+ # Portals write these as 1/0, and sometimes as "1"/"0".
+ tv_archive=str(row.get("enable_tv_archive") or "0") not in ("0", ""),
+ tv_archive_duration=str(row.get("tv_archive_duration") or ""),
+ )
+
def create_link(self, cmd: str) -> str:
"""Ask the portal for a playable URL for ``cmd``.
@@ -1387,10 +1410,24 @@ def create_link(self, cmd: str) -> str:
return link
def logo_url(self, logo: str) -> str:
- """Absolute URL for a channel logo, or '' when there isn't one."""
+ """Absolute URL for a channel logo, or '' when there isn't one.
+
+ Two shapes beyond the obvious, both from pvr.stalker's
+ ``DetermineLogoURI``, both seen in the wild:
+
+ * an inline ``data:`` image, which is dropped. It is a valid logo and a
+ useless one here -- Dispatcharr stores this in a URL field, and a
+ base64 payload has no business in an M3U attribute.
+ * any scheme at all, not just http(s). A portal serving its logos from
+ somewhere else says so with a scheme, and treating that as a filename
+ produced a URL that pointed nowhere.
+ """
+ logo = (logo or "").strip()
if not logo:
return ""
- if logo.startswith(("http://", "https://")):
+ if logo[:5].lower() == "data:":
+ return ""
+ if "://" in logo:
return logo
parsed = urlparse(self.cfg.url)
base_dir = parsed.path.rsplit("/", 1)[0] or ""
diff --git a/sync.py b/sync.py
index e46f526..1e4516d 100644
--- a/sync.py
+++ b/sync.py
@@ -67,6 +67,10 @@ def build_m3u(
``group-title`` carries the portal's own genre, which is what Dispatcharr
turns into Channel Groups -- so the existing M3U Accounts / Groups UI does
all the filtering, and this plugin does none.
+
+ Catch-up is deliberately not advertised here even though the portal tells
+ us about it and Dispatcharr would read it -- see ``ChannelEntry`` for why
+ the badge would be a promise nothing can keep.
"""
slug = portal.cfg.slug
lines = ["#EXTM3U"]
diff --git a/tests/test_listing.py b/tests/test_listing.py
new file mode 100644
index 0000000..9b7ec59
--- /dev/null
+++ b/tests/test_listing.py
@@ -0,0 +1,141 @@
+"""Reading a portal's channel list.
+
+What arrives is a Ministra response only on the portals that are Ministra.
+Everywhere else it is a rough approximation of one, and the parsing has to
+survive rows that are missing fields, logos in shapes that are not filenames,
+and -- once a portal declines to answer get_all_channels at all -- a listing
+that has to be collected a page at a time.
+"""
+import os
+import sys
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, REPO)
+
+import stalker_api as s # noqa: E402
+
+
+def portal(**cfg_kwargs):
+ cfg = s.PortalConfig(
+ slug="t", name="T", url="http://p.example/c/portal.php",
+ mac="00:1A:79:AA:BB:CC", **cfg_kwargs
+ )
+ return s.Portal(cfg)
+
+
+def row(**overrides):
+ base = {"id": "1", "name": "One", "cmd": "ffmpeg http://x/1", "number": "1"}
+ base.update(overrides)
+ return base
+
+
+# -- logos ---------------------------------------------------------------
+
+
+def test_a_bare_filename_is_resolved_against_the_portal():
+ assert portal().logo_url("canal.png") == (
+ "http://p.example/c/misc/logos/320/canal.png"
+ )
+
+
+def test_an_absolute_logo_is_left_alone():
+ for url in ("http://cdn.example/a.png", "https://cdn.example/a.png"):
+ assert portal().logo_url(url) == url
+
+
+def test_a_logo_on_some_other_scheme_is_still_a_url():
+ """It used to be treated as a filename and glued behind the logo path."""
+ assert portal().logo_url("ftp://cdn.example/a.png") == "ftp://cdn.example/a.png"
+
+
+def test_an_inline_image_is_dropped_rather_than_mangled():
+ """Valid, and useless here: it lands in a URL field on Dispatcharr's side.
+
+ The old test was that a logo starts with http, so this went through the
+ 'must be a filename' branch and produced .../misc/logos/320/data:image...
+ """
+ assert portal().logo_url("data:image/png;base64,iVBORw0KGgo=") == ""
+ assert portal().logo_url("DATA:image/png;base64,iVBORw0KGgo=") == ""
+
+
+def test_nothing_stays_nothing():
+ assert portal().logo_url("") == ""
+ assert portal().logo_url(" ") == ""
+
+
+# -- rows ----------------------------------------------------------------
+
+
+def test_a_row_becomes_a_channel():
+ channel = s.Portal._channel_from_row(
+ row(logo="a.png", tv_genre_id="7")
+ )
+ assert (channel.channel_id, channel.name, channel.number) == ("1", "One", "1")
+ assert (channel.logo, channel.genre_id) == ("a.png", "7")
+
+
+def test_rows_that_are_not_channels_are_skipped():
+ for bad in (None, [], "nope", row(cmd=""), row(name=""), row(name=" ")):
+ assert s.Portal._channel_from_row(bad) is None, bad
+
+
+def test_catch_up_is_read_off_the_row():
+ """Read, not published -- see ChannelEntry. This pins that it arrives."""
+ channel = s.Portal._channel_from_row(
+ row(enable_tv_archive=1, tv_archive_duration=7)
+ )
+ assert channel.tv_archive is True
+ assert channel.tv_archive_duration == "7"
+
+ # Portals write the flag as a string about as often as as a number.
+ assert s.Portal._channel_from_row(row(enable_tv_archive="1")).tv_archive is True
+ for off in (0, "0", "", None):
+ assert s.Portal._channel_from_row(row(enable_tv_archive=off)).tv_archive is False
+
+
+def test_a_channel_without_catch_up_says_so_quietly():
+ channel = s.Portal._channel_from_row(row())
+ assert channel.tv_archive is False and channel.tv_archive_duration == ""
+
+
+def test_the_playlist_does_not_advertise_catch_up():
+ """The badge would be a promise Dispatcharr cannot keep for a portal.
+
+ Its catch-up player builds Xtream URLs from a server address and
+ credentials a Distalker source has none of, so a channel flagged here
+ would show the indicator and then fail to play back.
+ """
+ import importlib.util
+ import types
+
+ pkg = types.ModuleType("distalker_listing")
+ pkg.__path__ = [REPO]
+ sys.modules["distalker_listing"] = pkg
+ spec = importlib.util.spec_from_file_location(
+ "distalker_listing.sync", os.path.join(REPO, "sync.py")
+ )
+ sync = importlib.util.module_from_spec(spec)
+ sys.modules["distalker_listing.sync"] = sync
+ spec.loader.exec_module(sync)
+
+ p = portal()
+ channels = [s.Portal._channel_from_row(row(enable_tv_archive=1,
+ tv_archive_duration=7))]
+ m3u = sync.build_m3u(p, channels, {})
+ assert "tv_archive" not in m3u, m3u
+ assert "catchup" not in m3u.lower(), m3u
+
+
+if __name__ == "__main__":
+ failures = 0
+ for name, fn in sorted(globals().items()):
+ if not name.startswith("test_") or not callable(fn):
+ continue
+ try:
+ fn()
+ print(f"PASS {name}")
+ except Exception as exc:
+ failures += 1
+ print(f"FAIL {name}: {type(exc).__name__}: {exc}")
+ print("\n" + ("ALL LISTING TESTS PASSED" if not failures else f"{failures} FAILURE(S)"))
+ sys.exit(1 if failures else 0)
From 6793ba99db47680b4d9a2dbc38e887ab0429f977 Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:42:36 +0200
Subject: [PATCH 06/20] Page the line-up out of portals that will not hand it
over at once
Some portals cap get_all_channels, some never implemented it. Either way
they were unusable here: the empty answer came back as "check the MAC
address", which was the wrong advice and the end of it. They can still be
browsed a page at a time, which is what the MAG interface does anyway, so
that is now the fallback -- hundreds of requests and several minutes on a
large bouquet, and worth it against a portal that otherwise cannot be synced
at all.
Three things end the walk, and all three are needed because each covers a
portal the others miss. The page count the portal implies from total_items
and max_page_items stops at exactly the right place, and is the bound
pvr.stalker uses. An empty page covers portals that run out politely, which
is open-tv's only guard. A page whose rows have all been read already covers
portals that clamp p to their last page and answer forever -- the case that
makes open-tv's loop infinite, since it declares both counts in its DTO and
then never reads them. A cap far above any real line-up catches the rest.
Order of preference is unchanged where it works: one request first, always,
because it is what nearly every portal supports and is enormously cheaper.
Paging is not tried against a session the portal has refused, since it would
be refused identically and a second rejected login is how a MAC gets
noticed; and when neither route finds anything, the original message
survives rather than being replaced by something vaguer -- an empty listing
really is a wrong MAC far more often than it is a portal needing pages.
The sync passes a progress callback so four silent minutes can account for
themselves. A callback and not a logger: this module stays Django-free.
---
CHANGELOG.md | 9 +++
README.md | 8 +-
stalker_api.py | 144 ++++++++++++++++++++++++++++++++++++
sync.py | 4 +-
tests/test_listing.py | 168 ++++++++++++++++++++++++++++++++++++++++++
5 files changed, 329 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1519688..cdd1e87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,15 @@
**Syncing**
+- **Portals that will not list their channels in one request now sync.** Some
+ cap `get_all_channels`, some never implemented it; either way the portal was
+ unusable, since the empty answer was reported as a probable wrong MAC. The
+ line-up is now collected a page at a time instead when that happens — slower
+ by a long way on a big bouquet, and the only way those portals work at all.
+ The sync log says when it has fallen back and how far along it is.
+- A portal that refuses the session is not paged as a second attempt, and an
+ empty listing from both routes still reports the original "check the MAC
+ address" message, which remains the likelier explanation.
- Channel logos survive two shapes that used to come out broken: a logo served
from another scheme than `http(s)` was treated as a filename and glued behind
the portal's logo path, and an inline `data:` image got the same treatment.
diff --git a/README.md b/README.md
index 080ec75..12ea734 100644
--- a/README.md
+++ b/README.md
@@ -273,8 +273,9 @@ button you should need.
### Sync only fetches what changed
A line-up is one request per portal that a busy provider can take minutes to
-assemble, so re-downloading portals that did not change is time spent for
-nothing:
+assemble — and on portals that refuse to list everything at once, hundreds of
+requests instead, collected page by page. Either way, re-downloading portals
+that did not change is time spent for nothing:
| Your line | What Sync does |
| --- | --- |
@@ -347,7 +348,8 @@ Resolver output otherwise appears in the channel's log, prefixed `[distalker]`:
| `cannot reach Redis (…); reading the mirrored portal instead` | Informational — playback carried on from the copy on disk. |
| `cached session rejected` | Normal. The token expired and is being renewed. |
| `create_link returned an empty command` | The portal refused the channel — often a connection limit or an expired subscription. |
-| `portal returned an empty channel list` | Wrong MAC address or portal URL. |
+| `portal returned an empty channel list` | Wrong MAC address or portal URL. Reported only after paging the line-up was tried too and also came back empty. |
+| `the portal would not list its channels in one request … collecting them a page at a time` | Informational. This portal caps or lacks `get_all_channels`, so the sync is reading its line-up page by page. Expect it to take minutes on a large bouquet. |
| ffmpeg: `Server returned 5XX Server Error reply` | The portal issued a link but refused to serve it. Probe it (below) — usually a connection limit. |
**Nothing plays and you see 503 / "max connections".** Every viewer, preview,
diff --git a/stalker_api.py b/stalker_api.py
index e7224d8..f75619c 100644
--- a/stalker_api.py
+++ b/stalker_api.py
@@ -82,6 +82,14 @@
# enough bill for covering the transient half.
RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
+# Last-resort bound on the paginated channel listing, for a portal that keeps
+# answering with something new and never says how much there is. Deliberately
+# far above any real line-up: whenever the portal reports 'total_items' the
+# computed page count wins long before this, and the two cheaper guards --
+# an empty page, a page that repeats one already read -- stop nearly everything
+# else. This only catches a portal generating rubbish indefinitely.
+ORDERED_LIST_PAGE_CAP = 1000
+
# Seconds before the 1st, 2nd and 3rd retry. Fixed rather than jittered: the
# calls that retry are made one portal at a time from a single process, so
# there is no herd to spread out, and a deterministic delay is one a test can
@@ -266,6 +274,18 @@ def extract_link(raw: str) -> str:
return ""
+def as_int(value: Any, default: int = 0) -> int:
+ """An int from whatever the portal felt like sending.
+
+ Numbers arrive as numbers on some portals and as strings on others, often
+ both within one response, so nothing that reads a count can assume either.
+ """
+ try:
+ return int(str(value).strip())
+ except (TypeError, ValueError):
+ return default
+
+
def slugify(value: str) -> str:
"""Reduce a display name to something safe for URLs, keys and filenames."""
slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
@@ -1376,6 +1396,130 @@ def _channel_from_row(row: Any) -> Optional[ChannelEntry]:
tv_archive_duration=str(row.get("tv_archive_duration") or ""),
)
+ def get_ordered_list(self, page: int) -> Dict[str, Any]:
+ """One page of the paginated listing, as the MAG interface browses it.
+
+ ``genre=*`` is every genre, which is what the box asks for on its "All"
+ screen. pvr.stalker leaves it out -- its request builder drops any
+ optional parameter still equal to its own default, and its default is
+ ``*`` -- but sending it says the same thing to portals that have no
+ such default to fall back on.
+ """
+ data = self._get_json(
+ "type=itv&action=get_ordered_list&JsHttpRequest=1-xml"
+ f"&genre=*&fav=0&sortby=number&p={int(page)}"
+ )
+ js = data.get("js") if isinstance(data, dict) else None
+ return js if isinstance(js, dict) else {}
+
+ def list_channels(self, progress=None) -> List[ChannelEntry]:
+ """Every channel, however this portal is willing to hand them over.
+
+ ``get_all_channels`` first, because one request for the whole line-up is
+ what nearly every portal supports and is enormously cheaper. Portals
+ that cap it, or never implemented it, answer with an error or with
+ nothing -- and used to leave the portal unusable. For those, the listing
+ is collected a page at a time instead: hundreds of requests where there
+ was one, several minutes where there were seconds, and worth every bit
+ of it because the alternative is a portal that cannot be synced at all.
+
+ ``progress`` is called with a line of English whenever there is
+ something worth saying, so a sync that has gone quiet for four minutes
+ can account for itself. Passed as a callback rather than a logger to
+ keep this module free of anything Django-shaped.
+
+ A portal that refuses the session is not asked twice: paging would be
+ refused for exactly the same reason, and the second refusal is the one
+ that would get a MAC noticed.
+ """
+ try:
+ return self.get_all_channels()
+ except PortalAuthError:
+ raise
+ except PortalError as exc:
+ single_shot_failure = exc
+
+ if progress:
+ progress(
+ f"the portal would not list its channels in one request "
+ f"({single_shot_failure}); collecting them a page at a time"
+ )
+
+ channels = self._paged_channels(progress)
+ if not channels:
+ # Nothing worked, so the first failure is the one worth reporting:
+ # it is the one whose message names the likely cause.
+ raise single_shot_failure
+
+ if progress:
+ progress(f"collected {len(channels)} channels by paging")
+ return channels
+
+ def _paged_channels(self, progress=None) -> List[ChannelEntry]:
+ """Walk get_ordered_list until one of three things says to stop.
+
+ All three are needed, because each covers a portal the others do not:
+
+ * the page count the portal itself implies, from ``total_items`` and
+ ``max_page_items`` on the first page. The bound pvr.stalker uses, and
+ the only one that stops at exactly the right place.
+ * a page with no rows. What a well-behaved portal does past the end,
+ and open-tv's only guard.
+ * a page whose rows have all been seen already. Portals that clamp ``p``
+ to their last page answer forever otherwise, which is the case that
+ turns open-tv's loop into an infinite one.
+ """
+ channels: List[ChannelEntry] = []
+ seen = set()
+ max_pages = ORDERED_LIST_PAGE_CAP
+ page = 1
+
+ while page <= max_pages:
+ js = self.get_ordered_list(page)
+ rows = js.get("data")
+ if not isinstance(rows, list) or not rows:
+ break
+
+ if page == 1:
+ implied = self._page_count(js)
+ if implied:
+ max_pages = min(max_pages, implied)
+ if progress:
+ progress(f"the portal reports {implied} pages to read")
+
+ fresh = 0
+ for row in rows:
+ channel = self._channel_from_row(row)
+ if channel is None:
+ continue
+ # The id when there is one, the command when there is not: two
+ # channels never share a command, and a portal that omits ids
+ # would otherwise collapse its whole line-up into one entry.
+ key = channel.channel_id or channel.cmd
+ if key in seen:
+ continue
+ seen.add(key)
+ channels.append(channel)
+ fresh += 1
+
+ if not fresh:
+ break
+
+ if progress and page % 20 == 0:
+ progress(f"page {page}, {len(channels)} channels so far")
+ page += 1
+
+ return channels
+
+ @staticmethod
+ def _page_count(js: Dict[str, Any]) -> int:
+ """How many pages the portal implies, or 0 when it does not say."""
+ total = as_int(js.get("total_items"))
+ per_page = as_int(js.get("max_page_items"))
+ if total > 0 and per_page > 0:
+ return (total + per_page - 1) // per_page
+ return 0
+
def create_link(self, cmd: str) -> str:
"""Ask the portal for a playable URL for ``cmd``.
diff --git a/sync.py b/sync.py
index 1e4516d..0cd48f8 100644
--- a/sync.py
+++ b/sync.py
@@ -437,7 +437,9 @@ def sync_portal(cfg: PortalConfig, logger, trigger_refresh: bool = True) -> Dict
if snapshot["blocked"]:
logger.warning("distalker: portal '%s' reports the account as blocked", cfg.name)
- channels = portal.get_all_channels()
+ channels = portal.list_channels(
+ progress=lambda note: logger.info("distalker: %s: %s", cfg.name, note)
+ )
genres = portal.get_genres()
# The resolver reads this at tune time; publish it before the M3U lands so
diff --git a/tests/test_listing.py b/tests/test_listing.py
index 9b7ec59..af2d699 100644
--- a/tests/test_listing.py
+++ b/tests/test_listing.py
@@ -126,6 +126,174 @@ def test_the_playlist_does_not_advertise_catch_up():
assert "catchup" not in m3u.lower(), m3u
+# -- paging --------------------------------------------------------------
+
+
+def scripted(all_channels, pages):
+ """A portal whose two listing calls answer from canned data.
+
+ ``all_channels`` is the get_all_channels payload, or an exception to raise.
+ ``pages`` maps a page number to its 'js' object; a page not in it answers
+ empty, which is what a portal past its last page does.
+ """
+ p = portal()
+ p.pages_asked = []
+
+ def fake_get_json(query, with_auth=True):
+ if "get_all_channels" in query:
+ if isinstance(all_channels, Exception):
+ raise all_channels
+ return all_channels
+ page = int(query.split("&p=")[1].split("&")[0])
+ p.pages_asked.append(page)
+ return {"js": pages.get(page, {"data": []})}
+
+ p._get_json = fake_get_json
+ return p
+
+
+def page(ids, **extra):
+ js = {"data": [row(id=str(i), name=f"Ch {i}", cmd=f"ffmpeg http://x/{i}")
+ for i in ids]}
+ js.update(extra)
+ return js
+
+
+REFUSED = s.PortalError("portal returned an empty channel list (check the MAC address)")
+
+
+def test_a_portal_that_answers_in_one_request_is_never_paged():
+ p = scripted({"js": {"data": [row()]}}, {})
+ assert len(p.list_channels()) == 1
+ assert p.pages_asked == [], "paging must stay the expensive last resort"
+
+
+def test_paging_takes_over_when_the_single_request_will_not():
+ p = scripted(REFUSED, {1: page([1, 2]), 2: page([3])})
+ channels = p.list_channels()
+ assert [c.channel_id for c in channels] == ["1", "2", "3"]
+ assert p.pages_asked == [1, 2, 3], p.pages_asked
+
+
+def test_the_reported_page_count_bounds_the_walk():
+ """The guard open-tv lacks: the portal said how much there was.
+
+ Its last page is full, so 'stop on an empty page' would ask for one more;
+ these pages never repeat, so 'stop on a repeat' would never fire either.
+ """
+ pages = {1: page([1, 2], total_items=4, max_page_items=2), 2: page([3, 4])}
+ p = scripted(REFUSED, pages)
+ assert len(p.list_channels()) == 4
+ assert p.pages_asked == [1, 2], p.pages_asked
+
+
+def test_a_page_count_sent_as_strings_still_counts():
+ pages = {1: page([1, 2], total_items="3", max_page_items="2"), 2: page([3])}
+ p = scripted(REFUSED, pages)
+ assert len(p.list_channels()) == 3
+ assert p.pages_asked == [1, 2], p.pages_asked
+
+
+def test_an_odd_remainder_gets_its_last_page():
+ pages = {1: page([1, 2], total_items=5, max_page_items=2),
+ 2: page([3, 4]), 3: page([5])}
+ p = scripted(REFUSED, pages)
+ assert len(p.list_channels()) == 5
+ assert p.pages_asked == [1, 2, 3], p.pages_asked
+
+
+def test_a_portal_replaying_its_last_page_does_not_loop_forever():
+ """Clamping 'p' instead of running out is common, and open-tv hangs on it."""
+ p = portal()
+ p.pages_asked = []
+
+ def fake_get_json(query, with_auth=True):
+ if "get_all_channels" in query:
+ raise REFUSED
+ p.pages_asked.append(int(query.split("&p=")[1].split("&")[0]))
+ return {"js": page([1, 2])} # the same two channels, always
+
+ p._get_json = fake_get_json
+ channels = p.list_channels()
+ assert [c.channel_id for c in channels] == ["1", "2"]
+ assert p.pages_asked == [1, 2], p.pages_asked
+
+
+def test_an_empty_page_ends_it():
+ p = scripted(REFUSED, {1: page([1, 2])})
+ assert len(p.list_channels()) == 2
+ assert p.pages_asked == [1, 2], p.pages_asked
+
+
+def test_the_hard_cap_catches_a_portal_inventing_channels():
+ """No total, never empty, never repeating: only the cap is left."""
+ p = portal()
+ counter = [0]
+
+ def fake_get_json(query, with_auth=True):
+ if "get_all_channels" in query:
+ raise REFUSED
+ counter[0] += 1
+ return {"js": page([counter[0]])}
+
+ p._get_json = fake_get_json
+ original = s.ORDERED_LIST_PAGE_CAP
+ s.ORDERED_LIST_PAGE_CAP = 5
+ try:
+ assert len(p.list_channels()) == 5
+ finally:
+ s.ORDERED_LIST_PAGE_CAP = original
+
+
+def test_duplicates_across_pages_are_collapsed():
+ p = scripted(REFUSED, {1: page([1, 2]), 2: page([2, 3])})
+ assert [c.channel_id for c in p.list_channels()] == ["1", "2", "3"]
+
+
+def test_when_neither_works_the_useful_message_survives():
+ """Paging must not replace 'check the MAC' with something vaguer.
+
+ An empty listing is far more often a wrong MAC than a portal that needs
+ paging, so the first failure stays the one the user is shown.
+ """
+ p = scripted(REFUSED, {})
+ try:
+ p.list_channels()
+ except s.PortalError as exc:
+ assert "MAC address" in str(exc), exc
+
+
+def test_a_refused_session_is_not_paged_at_all():
+ p = scripted(s.PortalAuthError("blocked"), {1: page([1])})
+ try:
+ p.list_channels()
+ except s.PortalAuthError:
+ pass
+ else:
+ raise AssertionError("a refusal must not be retried by another route")
+ assert p.pages_asked == [], "asking again is how a MAC gets noticed"
+
+
+def test_the_sync_is_told_what_is_happening():
+ notes = []
+ p = scripted(REFUSED, {1: page([1, 2], total_items=4, max_page_items=2),
+ 2: page([3, 4])})
+ p.list_channels(progress=notes.append)
+ joined = " | ".join(notes)
+ assert "page at a time" in joined, notes
+ assert "2 pages" in joined, notes
+ assert "4 channels" in joined, notes
+
+
+def test_the_page_request_asks_for_everything():
+ p = portal()
+ asked = []
+ p._get_json = lambda q, with_auth=True: asked.append(q) or {"js": {"data": []}}
+ p.get_ordered_list(3)
+ assert "genre=*" in asked[0] and "sortby=number" in asked[0], asked
+ assert "fav=0" in asked[0] and "&p=3" in asked[0], asked
+
+
if __name__ == "__main__":
failures = 0
for name, fn in sorted(globals().items()):
From 11f707523497c6274e819b7262b06291bdadbcd3 Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:42:43 +0200
Subject: [PATCH 07/20] Run the channel listing tests in CI
---
.github/workflows/ci.yml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 380d528..bea30b7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,9 @@ jobs:
- name: Request transport and retry test
run: python3 tests/test_transport.py
+ - name: Channel listing and paging test
+ run: python3 tests/test_listing.py
+
- name: Mock portal integration test
run: python3 tests/test_mock_portal.py
From 17b5bfeafb5fe1ba2d6da3b93c50987391ef1fd6 Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:32:42 +0200
Subject: [PATCH 08/20] Drop the dead watchdog, and find a portal on its other
endpoint
Two leftovers from reading pvr.stalker.
Portal.watchdog() had no caller and never could have one. Keeping a Stalker
session warm means calling get_events every 'timeslot' seconds, which needs
something alive between requests; the sync is a task that ends and the
resolver becomes ffmpeg. A ping method that exists and is never called reads
as a feature, so it is gone and a comment says why, with a test to stop it
coming back.
Ministra answers on both /c/portal.php and /server/load.php, and
installs differ in which they expose. Being handed the one a provider does
not serve meant a 404 and no suggestion. The handshake -- every session's
first request, so the only place a wrong path shows up -- now tries the
other one, and says which worked so the user can put it on the portal line.
Only PortalEndpointError earns that second attempt: a 404, or a reply that
is not JSON at all. A portal that is down, unwell, or refusing the MAC
answers identically on both paths, and at tune time a wasted round-trip is
time Dispatcharr is not spending on the next source.
self.url is kept apart from cfg.url because logos resolve against the
configured URL: swapping the API path must not move them.
---
CHANGELOG.md | 13 +++++
README.md | 5 ++
stalker_api.py | 126 ++++++++++++++++++++++++++++++++++++++++-----
tests/test_auth.py | 61 ++++++++++++++++++++++
4 files changed, 192 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdd1e87..60176a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
## Unreleased
+**After upgrading, press Test portals, then Re-fetch all.** Sync alone will
+report every portal as unchanged and fetch nothing: it compares your settings
+against what was last published, and none of them changed — only the code did.
+Test first, since it writes nothing and is where a portal that now needs
+credentials will say so.
+
**Connecting**
- **The portal now decides which authentication it gets.** Distalker reads the
@@ -32,6 +38,13 @@
- The MAC and the session token now travel in the query string as well as in
the cookie and the `Authorization` header. Portals read one form or the
other, and sending both costs nothing.
+- **A portal reached at the wrong path now finds itself.** Ministra answers on
+ both `…/c/portal.php` and `…/server/load.php`, and installs differ in which
+ they expose; being handed the one your provider does not serve used to mean a
+ 404 and no suggestion. The other path is now tried once, and the log says
+ which one worked so you can put it on the portal line and stop paying for the
+ failed request. Only a 404 or a reply that is not JSON earns the second
+ attempt — a portal that is merely down answers the same way on both.
**Syncing**
diff --git a/README.md b/README.md
index 12ea734..d1b3f09 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,11 @@ URL:
| `http://host/…/load.php` | unchanged — explicit endpoints are preserved |
| `http://host/c/other.php` | `http://host/c/portal.php` |
+If that path turns out not to be where the portal answers, Distalker tries the
+other one Ministra uses — `…/c/portal.php` and `…/server/load.php` are swapped
+for each other — and logs which one worked. Putting the working one on the
+portal line saves a failed request on every sync.
+
Anything unusual goes in trailing `key=value` pairs, separated by spaces or
further `|` characters, quoted where a value contains spaces
(`password="two words"`):
diff --git a/stalker_api.py b/stalker_api.py
index f75619c..907727e 100644
--- a/stalker_api.py
+++ b/stalker_api.py
@@ -198,6 +198,15 @@ class PortalError(Exception):
"""Raised when the portal rejects us or answers with nonsense."""
+class PortalEndpointError(PortalError):
+ """Something answered, but it was not a Stalker API.
+
+ A 404, or a body that is not JSON at all. Separated because it is the one
+ failure with a second thing worth trying: the same portal on its other
+ path -- see :func:`alternate_endpoint`.
+ """
+
+
class PortalAuthError(PortalError):
"""The portal understood us and refused the session.
@@ -598,6 +607,41 @@ def normalize_portal_url(url: str) -> str:
return parsed._replace(path=path).geturl()
+def alternate_endpoint(url: str) -> str:
+ """The other place a Stalker API lives, or '' when there isn't one.
+
+ Ministra answers on two paths and installs differ in which they expose:
+ ``/c/portal.php``, which is what :func:`normalize_portal_url` builds
+ and what most providers hand out, and ``/server/load.php``, which is
+ the older canonical one and the only one pvr.stalker has ever asked for.
+ A portal serving just one of them used to be unusable if the user had been
+ given the other, with a 404 and nothing to suggest.
+
+ The mapping is pvr.stalker's, read backwards as well as forwards::
+
+ http://h/c/portal.php -> http://h/server/load.php
+ http://h/stalker_portal/c/portal.php -> http://h/stalker_portal/server/load.php
+ http://h/server/load.php -> http://h/c/portal.php
+ """
+ parsed = urlparse(url)
+ path = parsed.path
+ directory, _, filename = path.rpartition("/")
+ filename = filename.lower()
+
+ if filename == "portal.php":
+ base = directory[:-2] if directory.lower().endswith("/c") else directory
+ new_path = base + "/server/load.php"
+ elif filename == "load.php":
+ base = directory[:-7] if directory.lower().endswith("/server") else directory
+ new_path = base + "/c/portal.php"
+ else:
+ return ""
+
+ if new_path == path:
+ return ""
+ return parsed._replace(path=new_path).geturl()
+
+
# ---------------------------------------------------------------------------
# Shared state: Redis, mirrored to disk
# ---------------------------------------------------------------------------
@@ -994,6 +1038,11 @@ def __init__(
retries: int = 0,
):
self.cfg = cfg
+ # Where the API is asked, which starts as the configured URL and may be
+ # swapped once by login() for the portal's other endpoint. Kept apart
+ # from cfg.url on purpose: that one is still what logos are resolved
+ # against, and swapping the API path must not move them.
+ self.url = cfg.url
self.token = token
# The portal's own setting unless a caller insists, so every request
# made about a portal honours what the user configured for it.
@@ -1091,7 +1140,7 @@ def _request(self, method: str, url: str, **kwargs) -> requests.Response:
raise PortalError(last_error or "request to portal failed")
def _get_json(self, query: str, with_auth: bool = True) -> Any:
- url = f"{self.cfg.url}?{query}&{self._common_params(with_auth)}"
+ url = f"{self.url}?{query}&{self._common_params(with_auth)}"
resp = self._request("GET", url, headers=self._headers(with_auth))
if resp.status_code in (401, 403):
@@ -1101,10 +1150,15 @@ def _get_json(self, query: str, with_auth: bool = True) -> Any:
if resp.status_code < 200 or resp.status_code >= 300:
snippet = (resp.text or "").strip()[:300]
- raise PortalError(
- f"portal returned HTTP {resp.status_code}"
- + (f": {snippet}" if snippet else "")
+ message = f"portal returned HTTP {resp.status_code}" + (
+ f": {snippet}" if snippet else ""
)
+ # A 404 is a web server saying nothing lives at this path -- which
+ # is a statement about the path, not about the portal, and login()
+ # has somewhere else to look.
+ if resp.status_code == 404:
+ raise PortalEndpointError(message)
+ raise PortalError(message)
try:
return resp.json()
@@ -1115,7 +1169,10 @@ def _get_json(self, query: str, with_auth: bool = True) -> Any:
# lets the resolver re-authenticate instead of failing the tune.
if snippet.lower() == AUTH_FAILED_BODY:
raise PortalAuthError("portal says the session is no longer authorised")
- raise PortalError(f"portal returned non-JSON response: {snippet}")
+ # Anything else that is not JSON is an HTML error page, a login
+ # form, or a landing page: something is listening, but it is not a
+ # Stalker API, so the other endpoint is worth a try.
+ raise PortalEndpointError(f"portal returned non-JSON response: {snippet}")
# -- authentication ---------------------------------------------------
@@ -1155,7 +1212,7 @@ def authenticate(self) -> None:
}
headers = self._headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
- url = f"{self.cfg.url}?{self._common_params()}"
+ url = f"{self.url}?{self._common_params()}"
resp = self._request("POST", url, data=form, headers=headers)
try:
@@ -1228,7 +1285,7 @@ def login(self) -> str:
explicit refusal (:class:`PortalAuthError`) is still fatal, because
that is the portal answering rather than failing to.
"""
- self.handshake()
+ self._handshake_on_either_endpoint()
try:
self.profile = self.get_profile()
@@ -1264,6 +1321,47 @@ def login(self) -> str:
return self.token
+ def _handshake_on_either_endpoint(self) -> None:
+ """Shake hands, trying the portal's other API path if this one is not it.
+
+ The handshake is every session's first request, so a portal reached at
+ the wrong path fails here and nowhere later -- which makes this the one
+ place worth spending an extra round-trip on.
+
+ Only a :class:`PortalEndpointError` earns that second try: a 404, or an
+ answer that is not JSON. A portal that is unreachable, unwell or
+ refusing the MAC would answer identically on both paths, and at tune
+ time a wasted round-trip is time Dispatcharr is not yet spending on the
+ next source.
+
+ The swap lasts for this session only. Nothing is written back, so the
+ cost is one failed request per sync and per token expiry -- small, and
+ the warning tells the user how to stop paying it for good.
+ """
+ try:
+ self.handshake()
+ return
+ except PortalEndpointError as exc:
+ alternate = alternate_endpoint(self.url)
+ if not alternate:
+ raise
+ first_failure = exc
+
+ self.url = alternate
+ try:
+ self.handshake()
+ except PortalError:
+ # The other path is no better. Report the original failure: it is
+ # the one about the URL the user actually configured.
+ self.url = self.cfg.url
+ raise first_failure
+
+ self.warnings.append(
+ f"the portal does not answer at {self.cfg.url} ({first_failure}), "
+ f"but does at {alternate}; put that on its portal line to save a "
+ "failed request on every sync"
+ )
+
@staticmethod
def _profile_status(profile: Dict[str, Any]) -> int:
"""``status`` as an int. Absent, blank or unparseable all mean OK."""
@@ -1328,12 +1426,14 @@ def account_snapshot(self) -> Dict[str, Any]:
return snapshot
- def watchdog(self) -> None:
- """Keep-alive ping. Only needed by portals that drop idle sessions."""
- self._get_json(
- "action=get_events&event_active_id=0&init=0&type=watchdog"
- "&cur_play_type=1&JsHttpRequest=1-xml"
- )
+ # There is no watchdog here, and its absence is a decision rather than an
+ # omission. Stalker clients keep a session warm by calling get_events every
+ # 'timeslot' seconds, which needs something alive between requests to do the
+ # calling. This plugin has no such thing: the sync is a task that ends, and
+ # the resolver is a process that becomes ffmpeg. A ping method existed for
+ # two releases with no caller, which is worse than not having one -- it read
+ # as a feature. Sessions are re-established instead, which is what the token
+ # cache and the resolver's re-authentication are for.
# -- content ----------------------------------------------------------
diff --git a/tests/test_auth.py b/tests/test_auth.py
index f24f883..1c41b3a 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -245,6 +245,67 @@ def test_a_reply_that_is_simply_not_a_link_is_not_an_auth_error():
pass
+def test_the_other_endpoint_is_tried_when_this_one_is_not_an_api():
+ """Ministra answers on two paths and installs expose different ones."""
+ tried = []
+
+ def handshake(query):
+ tried.append(len(tried))
+ if len(tried) == 1:
+ raise s.PortalEndpointError("portal returned HTTP 404")
+ return HANDSHAKE
+
+ p = portal({"handshake": handshake, "get_profile": {"js": {"status": 0}}})
+ assert p.login() == "TOK"
+ assert p.url == "http://p.example/server/load.php", p.url
+ # The configured URL is untouched, because logos are resolved against it.
+ assert p.cfg.url == "http://p.example/c/portal.php"
+ assert any("does at" in w for w in p.warnings), p.warnings
+
+
+def test_a_portal_that_is_simply_down_is_not_asked_twice():
+ """Both paths live on one host; a second try buys nothing but delay."""
+ p = portal({"handshake": s.PortalError("request to portal failed: refused")})
+ try:
+ p.login()
+ except s.PortalError as exc:
+ assert "refused" in str(exc), exc
+ assert len([q for q in p.queries if "handshake" in q]) == 1, p.queries
+
+
+def test_when_neither_endpoint_answers_the_configured_one_is_blamed():
+ p = portal({"handshake": s.PortalEndpointError("portal returned HTTP 404")})
+ try:
+ p.login()
+ except s.PortalError as exc:
+ assert "404" in str(exc), exc
+ # Reset, so nothing downstream reports a path the user never wrote.
+ assert p.url == p.cfg.url, p.url
+
+
+def test_the_two_endpoints_map_onto_each_other():
+ cases = {
+ "http://h/c/portal.php": "http://h/server/load.php",
+ "http://h/stalker_portal/c/portal.php": "http://h/stalker_portal/server/load.php",
+ "http://h/server/load.php": "http://h/c/portal.php",
+ "http://h:8080/c/portal.php": "http://h:8080/server/load.php",
+ # Nothing sensible to swap to.
+ "http://h/something.cgi": "",
+ }
+ for given, expected in cases.items():
+ assert s.alternate_endpoint(given) == expected, given
+
+
+def test_there_is_no_watchdog():
+ """Removed rather than left dead: nothing here can call one.
+
+ Keeping a Stalker session warm needs something alive between requests. The
+ sync is a task that ends and the resolver becomes ffmpeg, so a ping method
+ sat uncalled for two releases, reading as a feature that existed.
+ """
+ assert not hasattr(s.Portal, "watchdog")
+
+
def test_an_auth_error_is_still_a_portal_error():
"""Callers that only catch PortalError must not start leaking exceptions."""
assert issubclass(s.PortalAuthError, s.PortalError)
From 57723941a3ca41a7c7dd81d8f7a20b9539b85c73 Mon Sep 17 00:00:00 2001
From: PiloUnk <198624632+PiloUnk@users.noreply.github.com>
Date: Wed, 29 Jul 2026 03:21:06 +0200
Subject: [PATCH 09/20] Fetch a portal's programme guide, when it has one to
give
Portals answer get_epg_info with their whole line-up's guide, keyed by the
same channel id the playlist already carries in tvg-id -- put there in
anticipation of exactly this, so nothing had to be rewritten to make the two
meet. It becomes an XMLTV file and an EPGSource pointing at it: the mirror
image of the M3U account each portal already gets, and for the same reason.
Dispatcharr treats a source with a file_path and no url as a first-class
case, so the guide needs no HTTP endpoint either.
Off unless a line says epg=1, because it is by a wide margin the largest
thing a sync fetches. A portal with 13,000 channels answers a single day
with something around 100 MB, and the decoded form costs several times more
again -- so the reply is streamed to a scratch file and its size checked
before anything is decoded, and the document is generated by emptying that
structure as it writes rather than building a second copy of it.
None of which can fail the sync around it. The guide is an extra; a run that
ends with a working line-up and no guide is a good outcome, and one that
loses the line-up over a guide is not.
Three things learned from real portals rather than from reading:
Channels with no programmes are left out. A with nothing under it
still becomes a row in the EPG picker, and on the portal measured only 704
of 4,635 channels had a guide -- the rest would have been 3,900 permanent
empty promises.
Programmes overlap. The same show arrives twice with two start times and one
end, which is what a guide corrected in place looks like from outside, and
two candidates spanning one minute is an arbitrary answer to "what is on
now". Earliest start wins; touching exactly is not overlapping and is kept.
An empty guide is not an error but an unrecognised one is. Eight portals out
of twelve answer {"js": {"data": []}} -- thousands of channels and no
programmes for any of them, which is a property of the provider. Treating a
shape nobody has met as the same thing is how it would stay unmet, so that
one is reported and asks to be.
epg and epg_hours join the keys a change to which forces a fetch. Without
that the plan calls every portal unchanged, fetches nothing, and the setting
appears to do nothing at all -- and format_portal_line, which keeps only
what it is handed, would have deleted them from the line it rewrites.
---
CHANGELOG.md | 20 ++
CONTRIBUTING.md | 1 +
README.md | 40 +++-
plugin.json | 2 +-
plugin.py | 22 +-
stalker_api.py | 122 ++++++++++
sync.py | 344 +++++++++++++++++++++++++++-
tests/test_epg.py | 456 ++++++++++++++++++++++++++++++++++++++
tests/test_listing.py | 4 +-
tests/test_mock_portal.py | 42 +++-
10 files changed, 1041 insertions(+), 12 deletions(-)
create mode 100644 tests/test_epg.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60176a7..6444ed7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,26 @@ against what was last published, and none of them changed — only the code did.
Test first, since it writes nothing and is where a portal that now needs
credentials will say so.
+**Guide**
+
+- **Distalker can now fetch a portal's programme guide.** Add `epg=1` to a
+ portal line and the next sync writes an XMLTV file and registers it under
+ **EPGs** as `Distalker: ` — the same arrangement as the M3U account
+ it already creates for the channels. Matching is automatic: the `tvg-id` the
+ playlist has always carried is what Dispatcharr joins on.
+- Off unless asked for, because it is by a wide margin the largest thing a sync
+ downloads — a portal answers for its whole line-up at once, so a
+ 13,000-channel provider means roughly 100 MB for a single day. `epg_hours=48`
+ raises the default 24; a guide past 200 MB is abandoned with a message saying
+ to lower it.
+- A guide that fails never fails the sync around it. You keep the channel list.
+- Channels the portal has no programmes for are left out rather than written as
+ empty entries, and removing `epg=1` deactivates the EPG source instead of
+ deleting it.
+- A portal with no guide says so plainly and suggests removing `epg=1`, rather
+ than reporting the same message as a portal that answered with something
+ unrecognisable — which is now reported as exactly that, and asks to be.
+
**Connecting**
- **The portal now decides which authentication it gets.** Distalker reads the
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1f72a5a..a404c11 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,6 +36,7 @@ python3 tests/test_config.py # portal-line parsing, STB defaults, pseudo-U
python3 tests/test_auth.py # which authentication a portal asks for
python3 tests/test_transport.py # what a request carries, and when it is retried
python3 tests/test_listing.py # reading a portal's channel list
+python3 tests/test_epg.py # the guide, and the tvg-id both halves share
python3 tests/test_registry.py # surviving the settings panel
python3 tests/test_manifest.py # plugin.json vs plugin.py, and run()'s plumbing
python3 tests/test_fallback.py # non-portal sources on a Distalker channel
diff --git a/README.md b/README.md
index d1b3f09..bcf044e 100644
--- a/README.md
+++ b/README.md
@@ -173,6 +173,8 @@ further `|` characters, quoted where a value contains spaces
| `username` | Portal login, if your provider issued one | — |
| `password` | Portal password | — |
| `max_streams` | Concurrent connections allowed for this MAC | `1` |
+| `epg` | `epg=1` fetches this portal's programme guide — see below | off |
+| `epg_hours` | How much guide to ask for, in hours | `24` |
| *STB keys* | `model`, `serial`, `device_id`, `device_id2`, `signature`, `timezone` — see below | MAG254 |
> **`max_streams` cannot be detected.** Portals do not tell the box what the
@@ -323,11 +325,45 @@ dates natively. If the portal reports the account as blocked, the line says so
in capitals: a blocked account otherwise looks exactly like an empty channel
list.
+### Programme guide
+
+Add `epg=1` to a portal line and the next sync also fetches its guide, writes
+an XMLTV file, and registers it under **EPGs** as `Distalker: ` — the
+same arrangement as the M3U account it creates for the channels. Dispatcharr
+then matches programmes to channels on the `tvg-id` the playlist already
+carries, so nothing needs mapping by hand.
+
+```
+Living room | http://portal.example/c/ | 00:1A:79:AA:BB:CC | epg=1
+Living room | http://portal.example/c/ | 00:1A:79:AA:BB:CC | epg=1 epg_hours=48
+```
+
+**It is off by default because it is expensive.** A portal answers `get_epg_info`
+for its *entire* line-up in one response: on a 13,000-channel portal, a single
+day is on the order of 100 MB, and the period multiplies that directly. Start at
+the default 24 hours and raise it only if your portal copes. Beyond 200 MB the
+download is abandoned with a message telling you to lower `epg_hours`.
+
+**Not every portal has one.** A provider can carry thousands of channels and no
+programmes for any of them; it answers with an empty guide and the log says so,
+suggesting you drop `epg=1` from that line. Some serve a guide one channel at a
+time instead of as a whole grid — Distalker does not use that, because it costs
+one request per channel and, where it was measured, covered almost none of the
+channels anyone had actually configured.
+
+A guide that fails — too large, refused, in a shape this does not recognise — is
+logged and skipped. It never fails the sync around it, so you keep the channel
+list either way.
+
+Channels the portal has no programmes for are left out of the file rather than
+written empty, and turning `epg=1` off again deactivates the EPG source without
+deleting it.
+
## Limitations
- **Live TV only.** No VOD, no series.
-- **No EPG yet.** Generated `tvg-id`s are stable (`.`), so EPG
- can be added later without disturbing existing streams.
+- **The guide is fetched, never scheduled on its own.** It refreshes when you
+ press Sync, like everything else this plugin does.
- **Credentials are stored unencrypted**, in the Dispatcharr database and on
disk — see [What it writes, and where](#what-it-writes-and-where).
- **No session keep-alive.** A cached token is reused and re-issued on demand.
diff --git a/plugin.json b/plugin.json
index d819ef5..80ae02a 100644
--- a/plugin.json
+++ b/plugin.json
@@ -19,7 +19,7 @@
"type": "text",
"default": "",
"placeholder": "http://portal.example.com:8080/c/ | 00:1A:79:AA:BB:CC",
- "help_text": "Portal URL | MAC address, one line each. The name is taken from the host, so put one in front only if you want a different label -- or if two portals share a host, which the sync will then ask you to do. Trailing key=value pairs cover the rest: username, password, max_streams, model, serial, device_id, device_id2, timezone. A line starting with '#' is ignored, which is how you suspend a portal without losing its channels. Credentials are stored unencrypted and are visible in this box."
+ "help_text": "Portal URL | MAC address, one line each. The name is taken from the host, so put one in front only if you want a different label -- or if two portals share a host, which the sync will then ask you to do. Trailing key=value pairs cover the rest: username, password, max_streams, model, serial, device_id, device_id2, timezone, and epg=1 to fetch this portal's programme guide (epg_hours=48 for more than a day -- a guide is by far the largest thing a sync downloads, which is why it is off unless asked for). A line starting with '#' is ignored, which is how you suspend a portal without losing its channels. Credentials are stored unencrypted and are visible in this box."
},
{
"id": "status",
diff --git a/plugin.py b/plugin.py
index 64acb0b..d7aa1c2 100644
--- a/plugin.py
+++ b/plugin.py
@@ -33,6 +33,7 @@
save_registry,
)
from .stalker_api import (
+ DEFAULT_EPG_HOURS,
DEFAULT_FFMPEG_ARGS,
DEFAULT_TIMEOUT,
STB_KEYS,
@@ -206,6 +207,13 @@ def _migrate_legacy_globals(self, settings: Dict[str, Any], logger) -> Dict[str,
extras.get("password", ""),
int(extras.get("max_streams", 1) or 1),
stb,
+ # Carried through explicitly. This rewrite keeps only
+ # what it is handed, so anything left out here is
+ # silently deleted from the user's line.
+ extras.get("epg", "").strip().lower()
+ in ("1", "true", "yes", "on"),
+ int(extras.get("epg_hours", DEFAULT_EPG_HOURS)
+ or DEFAULT_EPG_HOURS),
)
)
changed += 1
@@ -487,6 +495,13 @@ def _action_test_portals(self, params, settings, logger) -> Dict[str, Any]:
"serial_number", "model", "timezone", "signature",
)
+ # Everything a change to which means the portal has to be asked again.
+ # The line-up keys, plus the guide: turning 'epg=1' on changes nothing
+ # about the channels, so without this the plan would call the portal
+ # unchanged, fetch nothing, and leave the user pressing Sync at a setting
+ # that appears to do nothing.
+ FETCH_KEYS = LINEUP_KEYS + ("epg", "epg_hours")
+
def _plan(self, portals: List[PortalConfig]) -> Dict[str, Any]:
"""Sort the configured portals into what needs the network and what does not.
@@ -505,7 +520,7 @@ def _plan(self, portals: List[PortalConfig]) -> Dict[str, Any]:
previous = load_portal(cfg.slug) if cfg.slug in published else None
if previous is None:
plan["new"].append(cfg)
- elif any(getattr(cfg, key) != getattr(previous, key) for key in self.LINEUP_KEYS):
+ elif any(getattr(cfg, key) != getattr(previous, key) for key in self.FETCH_KEYS):
plan["changed"].append(cfg)
else:
plan["unchanged"].append(cfg)
@@ -725,6 +740,11 @@ def _report(portals, plan, outcome, full, logger) -> str:
entry += f", expires {state['expires']:%d %b %Y}"
if just and just.get("blocked"):
entry += " -- THE PORTAL REPORTS THIS ACCOUNT AS BLOCKED"
+ # Dispatcharr's own guide toast names the source by its numeric id
+ # and nothing else (apps/epg/utils.py, send_epg_update), so this is
+ # the only place the portal's name and its guide appear together.
+ if just and just.get("epg"):
+ entry += f", guide for {just['epg']['channels']} of them"
if just:
entry += " (just fetched)"
lines.append(entry)
diff --git a/stalker_api.py b/stalker_api.py
index 907727e..dad011d 100644
--- a/stalker_api.py
+++ b/stalker_api.py
@@ -82,6 +82,20 @@
# enough bill for covering the transient half.
RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
+# Hours of guide asked for when a portal line says 'epg=1' without saying how
+# much. pvr.stalker holds itself to the same figure, and its own comment says
+# why: get_epg_info answers for the *entire* line-up in one response, so the
+# period is a direct multiplier on a download that is already the largest thing
+# this plugin ever makes.
+DEFAULT_EPG_HOURS = 24
+
+# Refuse a guide bigger than this rather than discover the limit by having the
+# worker killed. A 13,000-channel portal answers with something like 100 MB for
+# a day, and the parsed form of that costs several times more again -- so this
+# is not a generous allowance, it is the point past which the sensible thing is
+# to say so and keep the channel list, which matters more than the guide.
+EPG_MAX_BYTES = 200 * 1024 * 1024
+
# Last-resort bound on the paginated channel listing, for a portal that keeps
# answering with something new and never says how much there is. Deliberately
# far above any real line-up: whenever the portal reports 'total_items' the
@@ -240,6 +254,11 @@ class PortalConfig:
timezone: str = DEFAULT_TIMEZONE
signature: str = DEFAULT_SIGNATURE
max_streams: int = 1
+ # Off unless the line says otherwise. One guide is a bigger download than
+ # everything else this plugin fetches put together, so it is asked for
+ # rather than assumed -- see DEFAULT_EPG_HOURS.
+ epg: bool = False
+ epg_hours: int = DEFAULT_EPG_HOURS
ffmpeg_args: str = DEFAULT_FFMPEG_ARGS
# Travels to Redis with the rest, so the resolver waits as long as the sync
# does rather than giving up on a portal the sync copes with.
@@ -486,6 +505,19 @@ def parse_portals(text: str) -> Tuple[List[PortalConfig], List[str]]:
errors.append(f"line {lineno}: max_streams must be a number")
continue
+ # 'epg=1', 'epg=true', 'epg=yes' all mean the same thing to someone
+ # typing it from memory, so all of them are accepted; anything else
+ # -- including 'epg=0' -- leaves it off.
+ epg = extras.get("epg", "").strip().lower() in ("1", "true", "yes", "on")
+ try:
+ epg_hours = int(extras.get("epg_hours", DEFAULT_EPG_HOURS))
+ except ValueError:
+ errors.append(f"line {lineno}: epg_hours must be a number of hours")
+ continue
+ if epg_hours < 1:
+ errors.append(f"line {lineno}: epg_hours must be at least 1")
+ continue
+
def resolve(key: str, fallback: str) -> Tuple[str, bool]:
"""This portal's value, else the built-in default.
@@ -521,6 +553,8 @@ def resolve(key: str, fallback: str) -> Tuple[str, bool]:
timezone=timezone,
signature=signature,
max_streams=max_streams,
+ epg=epg,
+ epg_hours=epg_hours,
)
portals.append(cfg)
@@ -542,6 +576,8 @@ def format_portal_line(
password: str = "",
max_streams: int = 1,
stb: Optional[Dict[str, str]] = None,
+ epg: bool = False,
+ epg_hours: int = DEFAULT_EPG_HOURS,
) -> str:
"""Render one canonical line for the Portals setting.
@@ -565,6 +601,12 @@ def format_portal_line(
# seeing on lines they never wrote themselves.
if int(max_streams) != 1:
extras.append(f"max_streams={int(max_streams)}")
+ if epg:
+ extras.append("epg=1")
+ # Same rule as max_streams: only written when it says something the
+ # default does not already say.
+ if int(epg_hours) != DEFAULT_EPG_HOURS:
+ extras.append(f"epg_hours={int(epg_hours)}")
for key in STB_KEYS:
value = (stb or {}).get(key, "").strip()
@@ -1620,6 +1662,86 @@ def _page_count(js: Dict[str, Any]) -> int:
return (total + per_page - 1) // per_page
return 0
+ def get_epg_info(self, hours: int, scratch_dir: Optional[str] = None) -> Dict[str, Any]:
+ """The whole line-up's guide, keyed by the portal's channel id.
+
+ Deliberately not routed through :meth:`_get_json`, which is built for
+ replies that fit in a breath. This one does not: a portal with 13,000
+ channels answers a single day with something in the order of 100 MB,
+ and ``_get_json`` would hold the encoded bytes and the decoded object
+ at the same time.
+
+ So the reply is streamed to a scratch file first. That does not make
+ the parse cheaper -- the decoded structure is what it is -- but it does
+ two things worth the detour: nothing is decoded until the size is known,
+ so an absurd answer is refused instead of discovered by having the
+ worker killed; and requests never has to buffer the whole body.
+
+ Returns ``{channel_id: [programme, ...]}``, empty when the portal has
+ no guide to give. Never returns None, so a caller can iterate it.
+ """
+ query = (
+ "type=itv&action=get_epg_info&JsHttpRequest=1-xml"
+ f"&period={int(hours)}"
+ )
+ url = f"{self.url}?{query}&{self._common_params()}"
+ resp = self._request("GET", url, headers=self._headers(), stream=True)
+
+ if resp.status_code in (401, 403):
+ raise PortalAuthError(
+ f"portal refused the guide (HTTP {resp.status_code})"
+ )
+ if resp.status_code < 200 or resp.status_code >= 300:
+ raise PortalError(f"portal returned HTTP {resp.status_code} for the guide")
+
+ with tempfile.TemporaryFile(dir=scratch_dir) as scratch:
+ size = 0
+ for chunk in resp.iter_content(chunk_size=1024 * 256):
+ if not chunk:
+ continue
+ size += len(chunk)
+ if size > EPG_MAX_BYTES:
+ resp.close()
+ raise PortalError(
+ f"the guide is larger than {EPG_MAX_BYTES // (1024 * 1024)} MB "
+ f"and was abandoned; ask for fewer hours than {int(hours)} "
+ "with 'epg_hours=' on the portal line"
+ )
+ scratch.write(chunk)
+
+ scratch.seek(0)
+ try:
+ data = json.load(scratch)
+ except ValueError:
+ raise PortalError("the guide was not JSON")
+
+ js = data.get("js") if isinstance(data, dict) else None
+ if not isinstance(js, dict):
+ raise PortalError(
+ f"the guide came back as {type(js).__name__} rather than an object"
+ )
+
+ rows = js.get("data")
+ if isinstance(rows, dict):
+ return rows
+
+ # An empty list is how a portal says it has no full guide -- observed on
+ # several, always as `{"js": {"data": []}}`. Not an error: plenty of
+ # portals carry a channel list and no programmes for it, or serve a
+ # guide one channel at a time, which is a different action and not one
+ # this uses.
+ if rows is None or (isinstance(rows, list) and not rows):
+ return {}
+
+ # Anything else is a shape nobody has met yet, and silently treating it
+ # as "no guide" is how it would stay unmet. A flat list of programmes,
+ # say, would be perfectly usable if someone knew it was arriving.
+ raise PortalError(
+ f"the guide arrived as {type(rows).__name__} with "
+ f"{len(rows) if hasattr(rows, '__len__') else '?'} entries, which "
+ "is not a shape this understands -- please report it"
+ )
+
def create_link(self, cmd: str) -> str:
"""Ask the portal for a playable URL for ``cmd``.
diff --git a/sync.py b/sync.py
index 0cd48f8..4e95b95 100644
--- a/sync.py
+++ b/sync.py
@@ -12,14 +12,18 @@
import logging
import os
+import re
import tempfile
-from typing import Any, Dict, List, Optional
+from datetime import datetime, timezone
+from typing import Any, Dict, Iterator, List, Optional
+from xml.sax.saxutils import escape, quoteattr
from .stalker_api import (
ChannelEntry,
Portal,
PortalConfig,
PortalError,
+ as_int,
encode_pseudo_url,
python_executable,
save_fallback,
@@ -30,6 +34,11 @@
# generated playlists alongside user-uploaded ones and inside the data volume.
M3U_DIR = "/data/uploads/m3us"
+# The same idea for guides. Kept out of Dispatcharr's own 'cached_epg', which it
+# fills with files named after a source id -- ours are named after a portal and
+# are inputs to that machinery rather than products of it.
+XMLTV_DIR = "/data/uploads/epgs"
+
STREAM_PROFILE_NAME = "Distalker"
ACCOUNT_PREFIX = "Distalker: "
@@ -114,6 +123,162 @@ def write_m3u(slug: str, content: str) -> str:
return path
+# ---------------------------------------------------------------------------
+# XMLTV generation
+# ---------------------------------------------------------------------------
+#
+# Dispatcharr reads a guide with lxml's iterparse and takes five things from it
+# (apps/epg/tasks.py): a channel's id, its first display-name and its icon;
+# then each programme's channel, start, stop, title, desc and sub-title.
+# Everything else in the XMLTV vocabulary is ignored, so none of it is written.
+
+# Characters XML 1.0 has no way to carry. Portals do send them -- a stray 0x03
+# inside a programme description is enough to make lxml's recovery drop the
+# element around it, so they are removed rather than escaped.
+_ILLEGAL_XML = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
+
+
+def _xml_text(value: Any) -> str:
+ """A string safe to place between XML tags."""
+ return escape(_ILLEGAL_XML.sub("", str(value or "")))
+
+
+def _xmltv_time(value: Any) -> str:
+ """A Unix timestamp as XMLTV writes it, or '' if it is not one.
+
+ ``YYYYMMDDHHMMSS +0000``: exactly the 20 characters Dispatcharr's
+ ``parse_xmltv_time`` expects, and always UTC, because a portal's epoch is
+ an instant and the local time it corresponds to is nobody's business here.
+ """
+ seconds = as_int(value, -1)
+ if seconds <= 0:
+ return ""
+ try:
+ moment = datetime.fromtimestamp(seconds, timezone.utc)
+ except (OverflowError, OSError, ValueError):
+ return ""
+ return moment.strftime("%Y%m%d%H%M%S +0000")
+
+
+def build_xmltv(
+ portal: Portal,
+ channels: List[ChannelEntry],
+ epg_data: Dict[str, Any],
+) -> Iterator[str]:
+ """Yield an XMLTV document for the channels the portal has a guide for.
+
+ A generator, and ``epg_data`` is emptied as it goes: the guide for a large
+ portal is the biggest structure this plugin ever holds, and building the
+ document as one string would mean holding it twice. The caller writes each
+ piece out and nothing accumulates.
+
+ Only channels with at least one programme get an entry. A ````
+ with nothing under it still becomes a row in Dispatcharr's EPG table, and
+ 13,000 rows that will never match a programme are not a guide, they are
+ thirteen thousand empty promises in the channel-to-EPG picker.
+ """
+ slug = portal.cfg.slug
+
+ def tvg_id(channel: ChannelEntry) -> str:
+ # The identifier already written into the playlist. The two must agree
+ # exactly or nothing binds -- see test_the_playlist_and_the_guide_agree.
+ return f"{slug}.{channel.channel_id}" if channel.channel_id else ""
+
+ listed = [
+ channel for channel in channels
+ if tvg_id(channel) and epg_data.get(channel.channel_id)
+ ]
+
+ yield '\n'
+ yield '\n'
+
+ for channel in listed:
+ yield f" \n"
+ yield f" {_xml_text(channel.name)}\n"
+ logo = portal.logo_url(channel.logo)
+ if logo:
+ yield f" \n"
+ yield " \n"
+
+ for channel in listed:
+ # pop, not get: this is where the memory goes back.
+ programmes = epg_data.pop(channel.channel_id, None) or []
+ channel_id = quoteattr(tvg_id(channel))
+ for start, stop, title, description in _timeline(programmes):
+ yield (
+ f" \n"
+ )
+ yield f" {title}\n"
+ if description:
+ yield f" {description}\n"
+ yield " \n"
+
+ yield "\n"
+
+
+def _timeline(programmes: Any) -> Iterator[tuple]:
+ """One channel's programmes, in order and without overlaps.
+
+ Portals do send overlapping entries -- the same show listed twice with two
+ start times and one end, which is what a guide that has been corrected in
+ place looks like from outside. XMLTV permits it and readers do not expect
+ it: Dispatcharr picks a programme for an instant by searching the ones that
+ span it (``_match_epg_program_by_timeslot``), so two candidates for the same
+ minute is an arbitrary answer to "what is on now".
+
+ Earliest start wins, since it is the one covering the whole slot. Anything
+ beginning before the kept programme ends is dropped; touching exactly, which
+ is what back-to-back programmes do, is not an overlap and is kept.
+ """
+ usable = []
+ for programme in programmes if isinstance(programmes, list) else []:
+ if not isinstance(programme, dict):
+ continue
+ start = _xmltv_time(programme.get("start_timestamp"))
+ stop = _xmltv_time(programme.get("stop_timestamp"))
+ # A programme without both ends is not a programme. Dispatcharr would
+ # store it with a nonsense duration rather than reject it -- and the
+ # portal's own 'duration' field is no help: it arrives negative on
+ # programmes that plainly last two hours.
+ if not start or not stop or stop <= start:
+ continue
+ title = _xml_text(programme.get("name"))
+ if not title:
+ continue
+ usable.append((start, stop, title, _xml_text(programme.get("descr"))))
+
+ # The timestamps sort correctly as strings: fixed width, most significant
+ # first, and all of them UTC.
+ usable.sort(key=lambda item: (item[0], item[1]))
+
+ last_stop = ""
+ for entry in usable:
+ if entry[0] < last_stop:
+ continue
+ last_stop = entry[1]
+ yield entry
+
+
+def write_xmltv(slug: str, chunks: Iterator[str]) -> str:
+ """Stream a guide to disk atomically, as :func:`write_m3u` does for a playlist."""
+ os.makedirs(XMLTV_DIR, exist_ok=True)
+ path = os.path.join(XMLTV_DIR, f"distalker-{slug}.xml")
+
+ fd, temp_path = tempfile.mkstemp(dir=XMLTV_DIR, prefix=f".distalker-{slug}-")
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ for chunk in chunks:
+ handle.write(chunk)
+ os.replace(temp_path, path)
+ except Exception:
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
+ raise
+
+ return path
+
+
# ---------------------------------------------------------------------------
# Dispatcharr model wiring
# ---------------------------------------------------------------------------
@@ -172,6 +337,84 @@ def upsert_account(cfg: PortalConfig, file_path: str):
return account, created
+def distalker_epg_sources():
+ """Every EPG source this plugin owns, found by marker rather than by name."""
+ from apps.epg.models import EPGSource
+
+ return EPGSource.objects.filter(custom_properties__has_key=MARKER_KEY)
+
+
+def upsert_epg_source(cfg: PortalConfig, file_path: str):
+ """Create or update the EPG source backing one portal.
+
+ The mirror image of :func:`upsert_account`, and for the same reason: a
+ source with a ``file_path`` and no ``url`` is a first-class case in
+ Dispatcharr (``apps/epg/tasks.py`` takes the local-file branch when
+ ``not source.url``), so the guide costs no HTTP endpoint either.
+ """
+ from apps.epg.models import EPGSource
+
+ source = distalker_epg_sources().filter(
+ **{f"custom_properties__{MARKER_KEY}__slug": cfg.slug}
+ ).first()
+
+ created = False
+ if source is None:
+ source, created = EPGSource.objects.get_or_create(
+ name=ACCOUNT_PREFIX + cfg.name,
+ defaults={"source_type": "xmltv"},
+ )
+
+ source.name = ACCOUNT_PREFIX + cfg.name
+ source.source_type = "xmltv"
+ source.file_path = file_path
+ # Blank, and that is what selects the local-file branch. A source with both
+ # would be downloaded from the URL and our file ignored.
+ source.url = None
+ source.is_active = True
+ # We rewrite the file ourselves before asking for a re-parse, so a schedule
+ # of Dispatcharr's own would only re-read a file that had not changed.
+ source.refresh_interval = 0
+
+ properties = dict(source.custom_properties or {})
+ properties[MARKER_KEY] = {"slug": cfg.slug, "portal": cfg.url}
+ source.custom_properties = properties
+
+ source.save()
+ return source, created
+
+
+def deactivate_epg_source(cfg: PortalConfig) -> bool:
+ """Switch off the guide for a portal that no longer asks for one.
+
+ Not deleted: the plugin does not remove things a user can see and may have
+ configured around -- the same restraint that leaves an M3U account standing
+ when its portal line goes away. Deactivating is enough to stop a guide that
+ is no longer refreshed from binding itself to channels.
+ """
+ source = distalker_epg_sources().filter(
+ **{f"custom_properties__{MARKER_KEY}__slug": cfg.slug}
+ ).first()
+ if source is None or not source.is_active:
+ return False
+
+ source.is_active = False
+ source.save(update_fields=["is_active"])
+ return True
+
+
+def refresh_epg_source(source_id: int) -> None:
+ """Ask Dispatcharr to read the guide we just wrote.
+
+ Its own task, dispatched by name from a registered module rather than
+ defined here -- a task a plugin defines cannot be consumed at all, which is
+ the whole story recorded in tasks.py.
+ """
+ from apps.epg.tasks import refresh_epg_data
+
+ refresh_epg_data.delay(source_id)
+
+
def announce_new_account(account_id: int) -> None:
"""Tell the open UI that an M3U account it has never heard of now exists.
@@ -463,6 +706,8 @@ def sync_portal(cfg: PortalConfig, logger, trigger_refresh: bool = True) -> Dict
path,
)
+ epg = sync_epg(cfg, portal, channels, logger, trigger_refresh=trigger_refresh)
+
return {
"portal": cfg.name,
"slug": cfg.slug,
@@ -473,9 +718,106 @@ def sync_portal(cfg: PortalConfig, logger, trigger_refresh: bool = True) -> Dict
"file": path,
"expires": snapshot["expires"],
"blocked": snapshot["blocked"],
+ "epg": epg,
}
+def sync_epg(
+ cfg: PortalConfig,
+ portal: Portal,
+ channels: List[ChannelEntry],
+ logger,
+ trigger_refresh: bool = True,
+) -> Optional[Dict[str, Any]]:
+ """Fetch the guide and hand it to Dispatcharr. Returns None when off.
+
+ Runs last, and cannot fail the sync around it. That is the whole design of
+ this function: the guide is an extra, it is by a wide margin the largest
+ thing fetched here, and a portal has many more ways to disappoint over
+ 100 MB than over a channel list. A sync that ends with a working line-up
+ and no guide is a good outcome; one that loses the line-up because the
+ guide was too big is not.
+ """
+ if not cfg.epg:
+ # Inside its own guard for the same reason as everything else here: a
+ # portal that never wanted a guide must not fail its sync over one.
+ try:
+ if deactivate_epg_source(cfg):
+ logger.info(
+ "distalker: '%s' no longer asks for a guide; its EPG source "
+ "is switched off (not deleted)",
+ cfg.name,
+ )
+ except Exception:
+ logger.debug("distalker: could not check for a stale guide", exc_info=True)
+ return None
+
+ try:
+ logger.info(
+ "distalker: %s: fetching %d hours of guide for %d channels",
+ cfg.name,
+ cfg.epg_hours,
+ len(channels),
+ )
+ epg_data = portal.get_epg_info(cfg.epg_hours, scratch_dir=_scratch_dir())
+ if not epg_data:
+ # Said plainly, because it is a property of the provider rather
+ # than a fault to chase: a portal can carry thousands of channels
+ # and no programmes for any of them. Leaving 'epg=1' on costs one
+ # wasted request per sync and nothing else.
+ logger.warning(
+ "distalker: portal '%s' has no programme guide -- it answered "
+ "with an empty one. Remove 'epg=1' from its line to stop "
+ "asking.",
+ cfg.name,
+ )
+ return None
+
+ # build_xmltv empties epg_data as it writes, so nothing is counted
+ # afterwards -- count now, while it is still there to count.
+ covered = sum(1 for c in channels if epg_data.get(c.channel_id))
+
+ epg_path = write_xmltv(cfg.slug, build_xmltv(portal, channels, epg_data))
+ source, source_created = upsert_epg_source(cfg, epg_path)
+
+ if trigger_refresh:
+ refresh_epg_source(source.id)
+
+ logger.info(
+ "distalker: guide for '%s' -- %d channels covered -> %s",
+ cfg.name,
+ covered,
+ epg_path,
+ )
+ return {
+ "channels": covered,
+ "hours": cfg.epg_hours,
+ "file": epg_path,
+ "source_id": source.id,
+ "source_created": source_created,
+ }
+ except Exception as exc:
+ logger.warning(
+ "distalker: could not build the guide for '%s': %s", cfg.name, exc
+ )
+ logger.debug("distalker: guide failure detail", exc_info=True)
+ return None
+
+
+def _scratch_dir() -> Optional[str]:
+ """Where to stream a guide while it downloads.
+
+ Beside the finished file rather than in the system temp: the guide can be
+ hundreds of megabytes, and a container's /tmp is often a small tmpfs -- in
+ memory, which is precisely what streaming to a file is meant to avoid.
+ """
+ try:
+ os.makedirs(XMLTV_DIR, exist_ok=True)
+ return XMLTV_DIR
+ except OSError:
+ return None
+
+
def sync_all(portals: List[PortalConfig], logger, trigger_refresh: bool = True) -> Dict[str, Any]:
"""Sync every configured portal, surviving individual failures."""
results: List[Dict[str, Any]] = []
diff --git a/tests/test_epg.py b/tests/test_epg.py
new file mode 100644
index 0000000..a53865c
--- /dev/null
+++ b/tests/test_epg.py
@@ -0,0 +1,456 @@
+"""Turning a portal's guide into something Dispatcharr will read.
+
+The whole feature rests on one agreement: the identifier written into the
+playlist and the identifier written into the guide have to be the same string,
+because that is the only thing joining a channel to its programmes. Nothing
+warns when they drift -- the guide simply matches nothing -- so the first test
+here is the one that pins it.
+
+The rest is the parsing that has to survive a portal sending rubbish, and the
+promise that a guide which goes wrong never takes the channel list with it.
+"""
+import importlib.util
+import os
+import sys
+import types
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, REPO)
+
+import stalker_api as s # noqa: E402
+
+
+def load_sync():
+ """sync.py as Dispatcharr's loader sees it: a namespace package."""
+ pkg = types.ModuleType("distalker_epg")
+ pkg.__path__ = [REPO]
+ sys.modules["distalker_epg"] = pkg
+ spec = importlib.util.spec_from_file_location(
+ "distalker_epg.sync", os.path.join(REPO, "sync.py")
+ )
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["distalker_epg.sync"] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+sync = load_sync()
+
+
+def portal(**cfg_kwargs):
+ cfg = s.PortalConfig(
+ slug="mock", name="Mock", url="http://p.example/c/portal.php",
+ mac="00:1A:79:AA:BB:CC", **cfg_kwargs
+ )
+ return s.Portal(cfg)
+
+
+def channel(cid="101", name="One", logo=""):
+ return s.ChannelEntry(
+ channel_id=cid, name=name, cmd=f"ffmpeg http://x/{cid}", logo=logo,
+ genre_id="1", number="1",
+ )
+
+
+def programme(start=1785276000, stop=1785279600, name="Show", descr="Plot"):
+ return {"id": "9", "name": name, "descr": descr,
+ "start_timestamp": start, "stop_timestamp": stop}
+
+
+def xmltv(p, channels, epg_data):
+ return "".join(sync.build_xmltv(p, channels, epg_data))
+
+
+# -- the invariant --------------------------------------------------------
+
+
+def test_the_playlist_and_the_guide_agree_on_the_identifier():
+ """The one thing that must never drift.
+
+ tvg-id in the M3U, channel id in the XMLTV, and the 'channel' attribute on
+ every programme: three places, one string. If any of them changes shape the
+ guide stops matching and nothing says so.
+ """
+ p = portal()
+ channels = [channel("101"), channel("102", "Two")]
+ data = {"101": [programme()], "102": [programme()]}
+
+ playlist = sync.build_m3u(p, channels, {"1": "News"})
+ guide = xmltv(p, channels, data)
+
+ for cid in ("101", "102"):
+ expected = f"mock.{cid}"
+ assert f'tvg-id="{expected}"' in playlist, expected
+ assert f'' in guide, expected
+ assert f'channel="{expected}"' in guide, expected
+
+
+def test_a_channel_with_no_id_is_in_neither():
+ """No identifier, nothing to join on, so it is not offered a guide."""
+ p = portal()
+ channels = [channel("")]
+ guide = xmltv(p, channels, {"": [programme()]})
+ assert "')
+ assert "One" in guide
+ assert 'src="http://p.example/c/misc/logos/320/a.png"' in guide
+ assert "Show" in guide
+ assert "Plot" in guide
+ assert guide.rstrip().endswith("")
+
+
+# -- portals sending rubbish ----------------------------------------------
+
+
+def test_channels_without_programmes_are_left_out():
+ """An empty becomes a permanent empty row in the EPG picker."""
+ p = portal()
+ guide = xmltv(p, [channel("101"), channel("102", "Two")], {"101": [programme()]})
+ assert "mock.101" in guide
+ assert "mock.102" not in guide, guide
+
+
+def test_unusable_programmes_are_dropped_not_written():
+ p = portal()
+ bad = [
+ programme(start=0),
+ programme(stop=0),
+ programme(start=1785279600, stop=1785276000), # ends before it starts
+ programme(start=1785276000, stop=1785276000), # no duration
+ programme(name=""),
+ "not a dict",
+ None,
+ ]
+ guide = xmltv(p, [channel()], {"101": bad})
+ assert "Show" in guide
+ assert "" not in guide
+
+
+def test_markup_in_a_title_cannot_break_the_document():
+ p = portal()
+ guide = xmltv(
+ p, [channel(name='A & B <"x">')],
+ {"101": [programme(name="Tom & Jerry ", descr="1 < 2")]},
+ )
+ assert "&" in guide and "<" in guide
+ assert "" in guide.split("")[1][:60]
+ # Proof rather than inspection: it has to actually parse.
+ import xml.etree.ElementTree as ET
+
+ root = ET.fromstring(guide)
+ assert root.find("programme/title").text == "Tom & Jerry "
+ assert root.find("channel/display-name").text == 'A & B <"x">'
+
+
+def test_control_characters_are_removed_rather_than_escaped():
+ """XML 1.0 cannot carry them at all, and portals do send them."""
+ import xml.etree.ElementTree as ET
+
+ p = portal()
+ guide = xmltv(p, [channel()], {"101": [programme(descr="be\x03fore\x00after")]})
+ assert ET.fromstring(guide).find("programme/desc").text == "beforeafter"
+
+
+# -- overlapping entries ---------------------------------------------------
+
+
+def test_the_same_show_listed_twice_is_written_once():
+ """What a guide corrected in place looks like from outside.
+
+ Observed on a real portal: one show, one end time, two start times ten
+ minutes apart. Written as-is, Dispatcharr has two candidates spanning the
+ same minute and picks one arbitrarily for "what is on now".
+ """
+ p = portal()
+ both = [
+ programme(start=1785275700, stop=1785284400),
+ programme(start=1785276300, stop=1785284400),
+ ]
+ guide = xmltv(p, [channel()], {"101": both})
+ assert guide.count("go away