mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(stalker): keep session headers on same-host redirects (#1322)
* fix(stalker): keep session headers on same-host redirects Since 0.22 requestWithValidatedRedirects stripped Cookie/Authorization whenever a redirect changed the *origin*, so a portal answering with an http->https upgrade or a port move lost the MAC cookie and Bearer token mid-session. Real Stalker/Ministra servers then reply with a plain-text "Authorization failed." body: categories fail to load, create_link never resolves, and no player receives a stream URL (#1158 regression window). Scope credential stripping to the host instead: same-host scheme/port redirects keep headers, basic auth, params, and request bodies; a redirect to a different host still drops all of them, preserving the original hardening intent (no credential leaks to third-party hosts). Also adds the Stalker API compatibility roadmap produced by the 2026-08-01 protocol audit (.plans/, force-added like earlier plans). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(electron): strip credentials on same-host https-to-http downgrades Review follow-up (Greptile P1 + Codex on #1322): the host-only check kept Authorization/Cookie/basic auth/params/body when an https request was redirected to http on the same host, replaying a TLS-obtained session in cleartext. Treat that downgrade like a host change: strip credentials and refuse to replay request bodies. Scheme upgrades and port moves on the same host keep headers — the actual #1158 scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c59aace71
commit
46c58f4f57
5 files changed
+272
-8
No files matched your search
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
issues: [1158]
|
||||
---
|
||||
|
||||
Stalker portals whose server redirects to https or to another port no longer
|
||||
lose their session mid-request. Since 0.22 such redirects silently dropped the
|
||||
portal's MAC cookie and auth token, so categories failed to load and streams
|
||||
never reached any player. Downgrade redirects from https to plain http still
|
||||
strip credentials, so a secure session is never sent in cleartext.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Stalker/Ministra API Compatibility — Roadmap
|
||||
|
||||
Date: 2026-08-01. Source: full audit (codebase map + reference implementations + GitHub issues).
|
||||
Status legend: [ ] planned · [~] in progress · [x] merged.
|
||||
|
||||
## Reference facts (from Stalker 4.9.35 plaintext source `zfix/stalker-portal-4.9.x`, 5.1.1 client JS, Kodi pvr.stalker, stalkerhek)
|
||||
|
||||
These are the ground-truth rules a compatible client must follow. Re-verified 2026-08-01; do not re-research unless contradicted.
|
||||
|
||||
- Auth failure is **HTTP 200 + text/html body** — exact strings: `Authorization failed.`, `Access denied.`, `Unauthorized request.` Never a 401/403.
|
||||
- `Cookie: mac=<URL-encoded, UPPERCASE>` required on every request; `Authorization: Bearer <token>` required for everything except `handshake`, `get_profile`, `get_localization`, `do_auth`.
|
||||
- The **only** identity params the stock server enforces are `device_id`/`device_id2`: first non-empty value is pinned to the MAC forever; mismatch → `{status:1, msg:"device conflict …", block_msg:"Your STB is damaged…"}`. Empty on a fresh MAC is fine; empty after a value was pinned = permanent lockout.
|
||||
- `signature` is read but never verified (only passed to optional operator `access_filter.php`). `metrics`, `prehash`, `timestamp`, `api_signature` are **never read** by the server.
|
||||
- `signature` on a real box = `gSTB.GetUID(nonce)` firmware call; 4.9.x signs `access_token`, 5.x signs handshake `random`. Not reproducible client-side; `stalker-to-m3u` fabricates `SHA256(mac)`-based values and works.
|
||||
- MAC format validation is ON by default: `/^00:1A:79:[0-9A-F]{2}:…$/` (Infomir OUI, uppercase). Failure → bare `{status:1}`.
|
||||
- `get_profile` `status` decoded: full profile = OK; `1` = blocked (see `msg`/`block_msg`); `2` = login/password required → `do_auth` (params `login,password,device_id,device_id2`; response `{js:true|false}`) then `get_profile` with `auth_second_step=1`.
|
||||
- Handshake token = random 32 uppercase hex; **idempotent** (re-presenting a valid token returns it) → tokens can be persisted across restarts. Tokens have **no TTL**; they are only invalidated when another device does `get_profile` on the same MAC.
|
||||
- Watchdog `get_events` expected every **120 s** (`watchdog_timeout` echoed in profile + per-user `timeslot` jitter). Not calling it does NOT invalidate auth (only admin-panel "online" status); calling every 25 s is ~5× too often.
|
||||
- `create_link` should be called only when the channel row sets `use_http_tmp_link`/`use_load_balancing`; otherwise the static `cmd` plays directly. Temp links live **5 s** (`tv_tmp_link_ttl`), not single-use → resolve immediately before playback, never cache.
|
||||
- `cmd` format: `<solution> <url>` where solution ∈ {ffrt, ffrt2, ffrt3, ffmpeg, auto…} — strip by splitting on the FIRST space if present; bare URL is legal. Real MAG sends `cmd` **unencoded**.
|
||||
- `portal.php` does not exist in official Stalker/Ministra — it's a reseller-panel alias. Canonical endpoint derives from `/c/` → `<base>/server/load.php`. Robust discovery: probe `portal.php` → `server/load.php` → `stalker_portal/server/load.php` (→ optionally parse `ajax_loader` out of `/c/xpcom.common.js`).
|
||||
- Timezone cookie must be a valid PHP timezone or omitted (server does `new DateTimeZone()` on it).
|
||||
- Response envelope `{js, text}`: ignore `text` (carries `var_dump` noise).
|
||||
|
||||
## Audit findings (what's wrong in our code)
|
||||
|
||||
1. **0.22 regression (issue #1158):** `requestWithValidatedRedirects` strips `Cookie`/`Authorization` when `nextUrl.origin !== validatedUrl.origin` — scheme/port changes (http→https, :80→:8080) on the SAME host count as cross-origin, so redirected portals lose mac-cookie + token → `Authorization failed.` → no categories, no `create_link`, MPV "won't even launch". Introduced in 2c032cd3c (0.22). File: `apps/electron-backend/src/app/util/validated-axios.ts` (~line 240).
|
||||
2. Same commit made `cmd` percent-encoded (`encodeURIComponent` + slash restore); real MAG sends it raw → possible double-encoding when portal cmd already contains `%` and strict panels compare strings. Needs verification.
|
||||
3. Portal mode (`isFullStalkerPortal`) is guessed from URL shape, with TWO diverging predicates (`/stalker_portal/` w/ slash in session svc vs w/o slash at import); simple portals get NO handshake/token/watchdog; `…/c` is rewritten to `portal.php` which official portals don't have; no endpoint probing. Issues #850, #686, #755, #910.
|
||||
4. Built-in web players receive only User-Agent/Referer/Origin — `request-header-overrides.service.ts` cannot set Cookie/Authorization; token/mac-gated streams can never play in HTML5/Video.js/ArtPlayer/Shaka → the perennial "works only in VLC" cluster (#849, #910, #732).
|
||||
5. Stalker playback headers built ONLY for ITV (`with-stalker-player.feature.ts:244`); VOD/series/radio go to players with none. Cross-origin streams get `User-Agent: KSPlayer` profile with no cookie/token and it overrides caller headers. Same-origin sets `X-User-Agent` but not `User-Agent`.
|
||||
6. No `js.status` handling (status 2 → do_auth is dead code, import form login/password commented out), `msg`/`block_msg` never surfaced, only `Authorization failed` string caught (not `Access denied.` / `Unauthorized request.`), `not_valid`/`keep_alive` ignored, persisted `stalkerToken` never reused despite idempotent handshake.
|
||||
7. No MAC normalization/validation at import; raw string goes into cookie and `sha1(mac.toUpperCase())` prehash.
|
||||
8. Watchdog interval hardcoded 25 s (should be `watchdog_timeout` from profile, default 120).
|
||||
9. `create_link` called unconditionally; `use_http_tmp_link`/`use_load_balancing` never checked.
|
||||
10. PWA path much weaker than Electron: no MAG UA/X-User-Agent, cookie only `mac=`, no `JsHttpRequest=1-xml` injection, `sn` not stripped for non-get_profile, mac+token leak into portal query string, `cmd` slashes become `%2F`.
|
||||
11. Never-sent profile params: `ver`, `hw_version`, `image_version`, `client_type`; `stb_type` always `''`. Free to send, some `access_filter.php` deployments inspect them.
|
||||
12. Mock server implements neither `get_profile` nor `get_events`, validates no auth at all; e2e uses `portal.php` → full-portal auth surface has ZERO coverage.
|
||||
13. Two divergent `cmd` normalizers (`stalker-player-request.utils.ts` strong vs `stream-resolver.service.ts` weak, no base-path resolution).
|
||||
14. `.claude/skills/stalker-portal/SKILL.md` + `.codex` twin point at deleted `apps/web/src/app/stalker/*` paths.
|
||||
|
||||
## PR series (ordered for safety)
|
||||
|
||||
### PR 1 — fix(electron): keep auth headers on same-host redirects [PR #1322 open]
|
||||
Fixes the #1158 regression. In `validated-axios.ts`, strip `Cookie`/`Authorization`/`Proxy-Authorization` only when the redirect changes **host**, not on scheme/port change of the same host (curl semantics). Regression tests: http→https same host keeps headers; host change strips. Release note required. Branch: `claude/stalker-api-compatibility-7a1ecb` → https://github.com/4gray/iptvnator/pull/1322
|
||||
|
||||
### PR 2 — test(stalker): mock-server auth enforcement + e2e for the full-portal surface [~ in progress, branch claude/stalker-mock-auth-e2e]
|
||||
Pull coverage FORWARD before risky refactors: mock `get_profile` (validates Bearer, returns profile w/ configurable `status` 0/1/2, `msg`/`block_msg`), `get_events`, token enforcement returning literal `Authorization failed.` (200 text/html!) for missing/invalid Bearer, scenario MACs for device-conflict and status-2. E2E: full-portal import → handshake → get_profile → content → create_link → re-auth after token invalidation. This is the regression safety net for PRs 3–7.
|
||||
|
||||
### PR 3 — fix(stalker): verify/fix cmd encoding against reference behavior
|
||||
Reproduce with a `%`-containing cmd; decide raw-with-safe-charset vs decode-before-encode. Keep the injection protection from 2c032cd3c but avoid double-encoding. Unit tests with real-world cmd corpus (`ffrt3 http://…`, `/media/123.mpg`, tokens with `%3A`).
|
||||
|
||||
### PR 4 — feat(stalker): endpoint probing + behavior-based portal mode
|
||||
Replace URL-shape guessing: probe `portal.php` → `server/load.php` → `stalker_portal/server/load.php` at import (and once at runtime for legacy records); classify full/simple by observed handshake success, persist the resolved endpoint + mode; unify the two predicates. Migration for existing playlists. Fixes #850/#686/#755 class.
|
||||
|
||||
### PR 5 — feat(playback): forward Cookie/Authorization to built-in players + headers for VOD/series/radio
|
||||
Extend `request-header-overrides.service.ts` (scoped per stream origin/URL, cleared on playback end) to set Cookie + Authorization; extract headers in `web-player-view.component.ts`; build Stalker header set for VOD/series/radio, not just ITV; fix same-origin `User-Agent`; revisit KSPlayer cross-origin profile override. Fixes the "only VLC works" cluster (#849/#910/#732 for Stalker).
|
||||
|
||||
### PR 6 — feat(stalker): protocol-correct auth lifecycle
|
||||
- Parse `js.status`: 2 → revive `do_auth` (uncomment import form login/password), then `get_profile` with `auth_second_step=1`.
|
||||
- Surface `msg`/`block_msg` to the user (import dialog + portal error state).
|
||||
- Detect all three plain-text bodies (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) at the transport level instead of JSON.stringify regex.
|
||||
- Reuse persisted `stalkerToken` (handshake is idempotent), propagate `not_valid_token`.
|
||||
- Watchdog interval from profile `watchdog_timeout` (+`timeslot`), default 120 s instead of 25 s.
|
||||
|
||||
### PR 7 — feat(stalker): identity hardening
|
||||
- MAC normalization (uppercase, colon format) + validation with OUI `00:1A:79` hint in the import UI.
|
||||
- Optional deterministic device_id derivation (SHA256(mac), opt-in checkbox like StbEmu) with an explanation of the pinning/lockout semantics; never auto-change once set.
|
||||
- Send the free plausible params: `ver`, `stb_type` (real value), `hw_version`, `image_version`, `client_type`, `num_banks`, `video_out`, `hd`.
|
||||
- Device-conflict `msg` mapped to a human-readable error.
|
||||
|
||||
### PR 8 — fix(stalker): playback link semantics
|
||||
Respect `use_http_tmp_link`/`use_load_balancing` (static cmd when unset); never cache resolved links (5 s TTL); unify the two cmd normalizers into one shared util.
|
||||
|
||||
### PR 9 — fix(pwa): Stalker parity with Electron transport
|
||||
MAG UA/X-User-Agent, full cookie, `JsHttpRequest=1-xml` injection, strip `sn` for non-get_profile, stop leaking mac/token into the portal query string, slash-preserving cmd encoding in web-backend proxy.
|
||||
|
||||
### PR 10 — chore(docs/skills): sync
|
||||
Update `docs/architecture/stalker-portal.md` (watchdog, endpoint probing, auth lifecycle), fix stale `.claude/skills/stalker-portal/SKILL.md` + `.codex` twin paths, document protocol reference facts.
|
||||
|
||||
## Process
|
||||
- One PR per numbered item; each in its own thread/worktree with this file as the handoff. Update the status legend here as PRs land (and renumber if scope shifts).
|
||||
- Before opening each PR from a worktree: check `git log origin/master..HEAD` for inherited local commits (known trap).
|
||||
- Every behavior PR: `.changes/` release note; targeted tests per Regression Prevention policy.
|
||||
|
||||
## Related issues
|
||||
#1158 (0.22 regression, PR 1), #910/#849/#732 (VLC-only cluster, PR 5), #850/#686/#755 (URL/portal-mode, PR 4), #927/#860/#345/#448/#453 (identity fields, PR 7), #1146 (search, mostly shipped via #1209).
|
||||
@@ -297,7 +297,7 @@ describe('requestWithValidatedRedirects', () => {
|
||||
await expect(resolvePinnedAddress(1)).resolves.toBe('142.250.191.110');
|
||||
});
|
||||
|
||||
it('removes sensitive headers when a redirect changes origin', async () => {
|
||||
it('removes sensitive headers when a redirect changes host', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
@@ -330,7 +330,154 @@ describe('requestWithValidatedRedirects', () => {
|
||||
expect(redirectedConfig.headers).not.toHaveProperty('Cookie');
|
||||
});
|
||||
|
||||
it('does not forward axios params to a cross-origin redirect', async () => {
|
||||
it('keeps sensitive headers when a redirect upgrades the scheme on the same host', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: { location: 'https://portal.example/portal.php' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: { js: { token: 'abc' } },
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'http://portal.example/portal.php',
|
||||
{
|
||||
auth: { password: 'secret', username: 'provider' },
|
||||
headers: {
|
||||
Authorization: 'Bearer secret',
|
||||
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||
Accept: '*/*',
|
||||
},
|
||||
method: 'GET',
|
||||
params: { token: 'secret' },
|
||||
},
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
const redirectedConfig = axiosMock.mock.calls[1][0];
|
||||
expect(redirectedConfig.headers).toMatchObject({
|
||||
Accept: '*/*',
|
||||
Authorization: 'Bearer secret',
|
||||
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||
});
|
||||
expect(redirectedConfig.auth).toEqual({
|
||||
password: 'secret',
|
||||
username: 'provider',
|
||||
});
|
||||
expect(redirectedConfig.params).toEqual({ token: 'secret' });
|
||||
});
|
||||
|
||||
it('keeps sensitive headers when a redirect changes the port on the same host', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'http://portal.example:8080/server/load.php',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: { js: {} },
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'http://portal.example/server/load.php',
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Bearer secret',
|
||||
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||
},
|
||||
method: 'GET',
|
||||
},
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
const redirectedConfig = axiosMock.mock.calls[1][0];
|
||||
expect(redirectedConfig.headers).toMatchObject({
|
||||
Authorization: 'Bearer secret',
|
||||
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||
});
|
||||
});
|
||||
|
||||
it('replays the request body on a same-host scheme-upgrade redirect', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 307,
|
||||
headers: { location: 'https://portal.example/submit' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: 'ok',
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'http://portal.example/submit',
|
||||
{ data: { payload: true }, method: 'POST' },
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(2);
|
||||
expect(axiosMock.mock.calls[1][0].data).toEqual({ payload: true });
|
||||
});
|
||||
|
||||
it('strips sensitive headers when a same-host redirect downgrades https to http', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: { location: 'http://portal.example/portal.php' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: { js: {} },
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://portal.example/portal.php',
|
||||
{
|
||||
auth: { password: 'secret', username: 'provider' },
|
||||
headers: {
|
||||
Authorization: 'Bearer secret',
|
||||
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||
Accept: '*/*',
|
||||
},
|
||||
method: 'GET',
|
||||
params: { token: 'secret' },
|
||||
},
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
// A session obtained over TLS must never be replayed in cleartext.
|
||||
const redirectedConfig = axiosMock.mock.calls[1][0];
|
||||
expect(redirectedConfig.headers).toMatchObject({ Accept: '*/*' });
|
||||
expect(redirectedConfig.headers).not.toHaveProperty('Authorization');
|
||||
expect(redirectedConfig.headers).not.toHaveProperty('Cookie');
|
||||
expect(redirectedConfig.auth).toBeUndefined();
|
||||
expect(redirectedConfig.params).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a same-host https-to-http redirect that would replay a request body', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 307,
|
||||
headers: { location: 'http://portal.example/submit' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestWithValidatedRedirects(
|
||||
'https://portal.example/submit',
|
||||
{ data: { secret: true }, method: 'POST' },
|
||||
{ resolveHostname: publicResolver }
|
||||
)
|
||||
).rejects.toThrow(/request bodies/i);
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not forward axios params to a cross-host redirect', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
@@ -354,7 +501,7 @@ describe('requestWithValidatedRedirects', () => {
|
||||
expect(axiosMock.mock.calls[1][0].params).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not forward axios basic auth to a cross-origin redirect', async () => {
|
||||
it('does not forward axios basic auth to a cross-host redirect', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
@@ -378,7 +525,7 @@ describe('requestWithValidatedRedirects', () => {
|
||||
expect(axiosMock.mock.calls[1][0].auth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a cross-origin redirect that would replay a request body', async () => {
|
||||
it('rejects a cross-host redirect that would replay a request body', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 307,
|
||||
headers: { location: 'https://other.example/submit' },
|
||||
|
||||
@@ -237,10 +237,22 @@ export async function requestWithValidatedRedirects<T = unknown>(
|
||||
method: 'GET',
|
||||
};
|
||||
}
|
||||
if (nextUrl.origin !== validatedUrl.origin) {
|
||||
// Credentials are scoped to the host, not the origin: IPTV portals
|
||||
// routinely redirect between schemes and ports of the same host
|
||||
// (http -> https upgrades, port moves), and stripping the session
|
||||
// cookie/token there breaks the portal outright (#1158). Two hops do
|
||||
// lose Authorization/Cookie/basic auth/params: a *different* host
|
||||
// (credentials never leak to third parties) and a same-host
|
||||
// https -> http downgrade (a session obtained over TLS is never
|
||||
// replayed in cleartext).
|
||||
const isCredentialUnsafeRedirect =
|
||||
nextUrl.hostname !== validatedUrl.hostname ||
|
||||
(validatedUrl.protocol === 'https:' &&
|
||||
nextUrl.protocol === 'http:');
|
||||
if (isCredentialUnsafeRedirect) {
|
||||
if (requestConfig.data !== undefined) {
|
||||
throw new UnsafeUrlError(
|
||||
'Cross-origin redirects with request bodies are not supported',
|
||||
'Redirects that change the host or downgrade to HTTP cannot replay request bodies',
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,8 +164,15 @@ validation while retaining the original hostname for TLS SNI, certificate
|
||||
validation, and virtual hosting. This prevents DNS rebinding between validation
|
||||
and connection. Callers with custom TLS policy provide a typed agent factory;
|
||||
the validated request layer supplies the pinned lookup instead of copying
|
||||
private Node `Agent.options` state. Cross-origin redirects must not forward `Authorization`,
|
||||
`Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies.
|
||||
private Node `Agent.options` state. Credential stripping is scoped to the host
|
||||
rather than the origin, with one transport-security carve-out: same-hostname
|
||||
redirects that upgrade the scheme (http→https) or move ports keep
|
||||
`Authorization`, `Cookie`, basic auth, `params`, and request bodies, because
|
||||
IPTV portals routinely answer with such redirects and losing the session
|
||||
cookie/token there breaks the portal outright (#1158) while disclosing nothing
|
||||
to a third party. Redirects that change the host **or downgrade https→http**
|
||||
must not forward any of those — the former would hand provider credentials to a
|
||||
third party, the latter would replay a TLS-obtained session in cleartext.
|
||||
|
||||
EPG URLs are strict by default because an M3U playlist can supply them through
|
||||
`url-tvg`. Operators who intentionally use a LAN-hosted EPG source should prefer
|
||||
|
||||
Reference in new issue
Block a user