Skip to content

v0.9.4 - #4

Merged
PiloUnk merged 10 commits into
mainfrom
dev
Aug 24, 2026
Merged

PiloUnk merged 10 commits into
mainfrom
dev

Conversation

@PiloUnk

@PiloUnk PiloUnk commented Aug 24, 2026

Copy link
Copy Markdown
Owner

No description provided.

PiloUnk and others added 10 commits August 24, 2026 22:34
The stale-token answer was matched by equality against "authorization
failed.", and the stock Ministra server appends a debug counter to it. So
"Authorization failed. 75" -- what it actually sends -- was not recognised,
and neither were the other two refusals in the same family: "Access denied."
for a blocked account, "Unauthorized request." when the mac cookie never
arrived. All three arrived typed as PortalEndpointError, which has two wrong
consequences. The resolver re-authenticates on PortalAuthError alone, so the
one answer the cached-token path exists to survive failed the tune instead;
and the handshake retries the portal's other path on an endpoint failure, so
a portal that was refusing us was asked a second time -- the request the
comment there already calls the one that gets a MAC noticed.

The bodies are matched against the whole body rather than searched for inside
it. They are bare phrases, so a proxy page reading "<html><body>Access
denied</body></html>" -- 38 characters, under any length a cap would catch --
would otherwise be read as the portal speaking, and send the resolver
re-authenticating against a host that never answered at all.

Panels that are not Ministra refuse inside the envelope rather than in place
of it, and those were not seen anywhere: get_genres answered {'js': {'error':
'Invalid token'}} counted as a portal with no genres, so Test portals reported
it as authenticated with 0 groups, and get_all_channels on the same reply told
the user to check a MAC address that was never the problem. handshake() had
the same hole from the other side -- a reply with no token left the cached one
in place and returned it as if it had succeeded.

Two vocabularies, because the inputs are not comparable. A raw body is an
arbitrary document and gets the three anchored phrases; 'error' is a field a
panel filled in deliberately -- a reply that worked has none at all -- so
'Invalid token' and a bare 'unauthorized' are worth reading there. 'msg' gets
the exact bodies only, and only when the reply carries no 'status': a reply
that has one has a verdict login() reads for itself, and a status-2 portal
saying "Authorization required" is asking for a password, not refusing us.
Raising here would skip the do_auth it was asking for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three clients now agree on more of the STB handshake than this one was
sending, and the parts missing here are all read by the same thing: the
optional access_filter.php a reseller installs in front of Ministra. The stock
middleware ignores every one of them, which is why their absence has never
shown up as a failure anyone could trace -- a portal that runs a filter simply
refuses, and says nothing about which field made it.

- 'prehash' on the handshake and the profile. iptvnator computes the SHA1 of
  the upper-case MAC, QiTV ships one constant for every install it has ever
  run; only the first says anything true about this box, and if a portal ever
  checks the value against its own record, a constant shared by every user of
  one client is the version that fails.
- 'metrics', the JSON blob a portal stores and shows its operator, so a device
  list has a MAG in it rather than a blank row. It echoes the nonce the
  handshake handed back, which is the part a portal can actually check: it
  issued that value one request ago, and only something that read the answer
  can quote it.
- 'client_type' and 'video_out', which both other clients send and this one
  never did.

get_ordered_list gains 'category', 'force_ch_link_check' and 'hd' for the same
reason _common_params sends the identity three ways: a Ministra portal reads
'genre' and ignores the rest, the clones do not all read the same one, and the
cost of saying it several ways is a longer query string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_link encoded the command with quote(cmd, safe=""), which escapes the
percent sign along with everything else. The portal's own client does not: it
concatenates the command into the query string and lets the URL layer escape
only what a URL cannot carry, so PHP form-decodes it exactly once and a
command that already contains '%3A' arrives with that escape intact.

Ours arrived as '%253A' -- still encoded, a different string from the one a
real box sends, and the stock create_link handler matches on it with
preg_match. The commands this could happen to are the ones a portal answered
its listing with as a resolved URL, which are exactly the ones canonical_cmd
declines to rewrite when they carry no channel id: worst where there is least
left to recover it.

The safe set is the one a URL serializer keeps raw in a query, minus nothing
that matters: '&', '#' and ';' stay encoded, so a portal cannot restructure
our own request by putting them in a command.

'disable_ad' and 'download' go out beside it. Neither changes what a live
channel answers; both are what a portal expecting its own client sees on every
request except this plugin's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The endpoint cannot be worked out from what a user pastes. Ministra serves
<base>/stalker_portal/server/load.php; reseller panels serve <base>/c/portal.php
or <base>/portal.php, and some serve neither, from a path of their own.
pvr.stalker knows the first pair and stalkerhek the second, this plugin knew
the second, and a portal on any of the others could not be reached at all.

So alternate_endpoint, which was a mapping, becomes endpoint_candidates, which
is a list to probe. Its second entry is exactly what the mapping used to
return, both ways round, so a portal found on the second try before is found
on the second try still; the rest are added after it.

The other half is that normalize_portal_url no longer overrides a .php the
user pasted. Swapping any other .php for portal.php in the same directory is
stalkerhek's rule and the one departure from it here: a panel under a path of
its own is not a typo, it is the address the provider handed out, and
replacing it with a guess is how the one URL known to work stops being tried.
The standard siblings are probed after it, so nothing is lost by trying it
first.

Walking the list stays bounded by the rule that was already there: only an
answer that is not a Stalker API moves to the next candidate. Every path lives
on the same host, so a portal that is down, unwell or refusing the MAC fails on
the first one -- at tune time a wasted round-trip is time Dispatcharr is not
yet spending on the next source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The expiry.** It was read from get_main_info's 'phone', which is where a
reseller types it because Ministra shows that field in the MAG interface. But
Ministra's own answer carries it too, as a Unix timestamp in the account_info
block of the profile login() has already read -- free, and the authoritative
one where it exists. get_main_info is now only asked when that came up empty,
and its 'end_date' and 'expire_billing_date' are read alongside 'phone'.

Timestamps needed the parser to grow two rules. A portal writes "never" as 0
or -1, and reading either as a date reports every unlimited account as having
run out in 1970 -- the one wrong answer worse than no answer at all; and the
same value arrives in milliseconds from portals that send it that way.

**The refusal.** Portals write these with markup in them ("Subscription
expired.<br/> Call us."), and it was going into a Dispatcharr notification
with the tags still in it.

A device conflict now says what to do about it. It is the only refusal here a
user can act on, and the portal's own wording names the box rather than the
binding, so the message arrived describing a broken STB instead of two
settings on the portal line. Matched on the binding alone: "device limit
reached" has no remedy, and offering one would be worse than saying nothing.

**A repeated channel.** get_all_channels was deduplicated only on the paged
path. Portals do repeat a row there, and Dispatcharr hashes a stream partly on
its URL, so a duplicate is a second stream for one channel rather than an
extra line nobody sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A MAG sends the mac cookie and the session token to everything it fetches from
the portal, and panels that gate the stream on that cookie answer a request
without one with a 403. This plugin sent neither, so those channels
authenticated, resolved a link, and then would not play -- with nothing in the
ffmpeg output to say which of the three steps had actually failed.

They go out now, but only where a box would send them. A create_link URL
frequently points somewhere that is nobody's business but its own -- a CDN, an
operator's edge, another provider entirely -- and it carries its own token in
the query, so it needs nothing from us. Sending the MAC and the session token
there would hand a third party everything required to use the subscription.

Same host, and never a downgrade from https to http. A different port is still
the portal: panels routinely serve the stream from :8080 next to the portal on
:80, and those are exactly the ones gated on the cookie.

resolve() hands the token back with the link because only the session that
minted a link is the one the stream will accept -- reading the cache again
would race the re-authentication the optimistic path may just have done.
--probe reports which of the two shapes it sent, since "resolved but 403" is
the failure this exists to explain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Stalker row carries use_http_tmp_link and use_load_balancing, and the
portal's own player.js calls create_link only when one of them is set --
everything else plays the cmd the listing already gave. pvr.stalker does the
same and cites that line of player.js for it; iptvnator does it again with
guards for portals that are not Ministra. This plugin asked for a link every
time.

On nearly every portal that changes nothing, and the reason is worth writing
down: a stock row is 'ffmpeg http://localhost/ch/123_', which only the portal
can resolve whatever its flags say -- and canonical_cmd rewrites the rows that
arrive as resolved links into exactly that shape. pvr.stalker has no loopback
guard at all, which is itself evidence that Ministra sets the flag on those
rows: an ecosystem depends on it.

The family this is for is the one undoubled_link was written for. Those
providers answer the listing with a resolved link, and answer create_link by
gluing their own base in front of it -- producing a path that returns 401, so
the reply and its token are thrown away and the command is played anyway. That
is one request per tune, spent to arrive back where it started, against
providers that count connections.

Four guards, each of which falls back on asking the portal, so none of them can
break an install that works today. Absent flags are no evidence rather than a
no -- every portal synced before this carries neither, and reading silence as
"static" would move a whole installation at once. The scheme has to be one
ffmpeg opens, which is not the question extract_link answers: a portal's own
ffrt4:// pseudo-URL parses like an address and plays as nothing. The host must
not be loopback, in rather more spellings than the obvious one.

The fourth is ours rather than anybody's reference behaviour, and it is what
makes this safe in a resolver rather than in a player: no query string. A link
that expires carries its token there, and being wrong does not cost a retry --
by then the resolver has become ffmpeg and Dispatcharr has spent this channel's
failover on a source that resolved perfectly well.

The set of qualifying commands is published like the portal itself, Redis with
a disk mirror, so a restart does not quietly turn the optimisation off. It is
rewritten on every sync including when empty: a portal that has stopped marking
its channels static must not inherit the list that said otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Portals box was the one place a MAC, a password and the rest of the
account identity were rendered back to the user, and Dispatcharr serves a
plugin's settings row to every account on the install -- so they were also in
an API response anyone could read.

The row now holds a redacted rendering and the real list lives only in
portals.txt, which the panel cannot reach. Redaction is a property of the
write path alone: _reconcile_registry undoes it on the way in, so every
migration, action and sync goes on reading plain lines.

Names, URLs and the tuning keys stay readable. A box of nothing but bullets
is one nobody can recognise their own portals in, and the rule is what a
value proves rather than what it configures.

Two guards decide the whole thing. Nothing is redacted until portals.txt is
known to hold the same list -- hiding the only copy of a credential is how a
configuration gets lost -- and redacted text is never mirrored back over that
file, because _failed() can be reached with settings that never passed
through the reconciliation.

This hides the credentials; it does not encrypt them. The resolver reads the
MAC on every tune in a process with no Django, so portals.txt and the state
mirrors go on holding it in the clear at 0600, and the README says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Taking a field out of plugin.json only stops the panel rendering it. The
value stays in PluginConfig.settings, which Dispatcharr serves to every
account on the install -- so the Add-portal form removed in 0.4.0 left a MAC,
a password and a portal URL in the API response for a month, belonging to a
portal the user had long since deleted. sync_interval_hours went the same way
with the schedule.

Redacting the portal list and leaving those behind would have been half a
job, so anything the manifest does not declare is now dropped from the row.
The manifest is the single definition of the panel, and test_manifest.py
already pins that every setting the code reads is declared, so the set is
safe by construction.

Ordering matters: it runs after the migrations, whose whole input is keys the
manifest stopped declaring three versions ago and which would otherwise be
taken out from under them. And it runs on every action rather than once,
because an open panel replays the stale keys until the page is reloaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lines on the release page, because that is what somebody deciding whether
to upgrade needs: the portals this now talks to, and the credentials it stops
showing. The reasoning behind each sits below the details marker, for whoever
comes looking for it in the file.

Test and Re-fetch all are named at the top for the same reason as 0.9.3: only
the code changed, so Sync compares your settings against what was published,
finds nothing moved, and fetches nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PiloUnk
PiloUnk merged commit 252302e into main Aug 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant