mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-17 13:22:29 +02:00
More fix hrti
This commit is contained in:
@@ -20,6 +20,13 @@ template is ever registered.
|
||||
> Statements about things *not* seen are marked **(unverified)**. The first
|
||||
> provider you migrate against this README is the real validation — please
|
||||
> note every place where the README was wrong or silent, and report it.
|
||||
>
|
||||
> **Revision note (third migration, HRTi):** nothing in the README was wrong, but it
|
||||
> was silent in places that are now covered: client identity (§5.1), details endpoints
|
||||
> that need a parent id (§6.3), the boundary of the required/optional rule (§8),
|
||||
> server-side token invalidation, the recovery ladder and its concurrency rules (§9),
|
||||
> persisted-state and logging checks, staged registration and how code is handed to a
|
||||
> migrator (§12.1b, §12.3b), plus Traps 57–65. Two hedges are still unbuilt (§8, §17).
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +93,8 @@ python _template_v2/scaffold.py joyn Joyn --label "Joyn" --country DE \
|
||||
`provider.py`. (Without the scaffold: copy the directory, replace the tokens
|
||||
by hand, move files out of `optional/`, delete `optional/` and `scaffold.py`.
|
||||
Optional files use `from ....base` one level deeper; after moving it must be
|
||||
`from ...base`.)
|
||||
`from ...base`.) Pasted snippets must work as they are: no `# ADAPT:` comment
|
||||
may survive into committed code (§12.3b, Trap 61).
|
||||
3. **`constants.py`**: fill in hosts, paths, headers, `SUPPORTED_COUNTRIES`.
|
||||
Nothing else may contain URLs or header values.
|
||||
4. **`models.py`**: write the `from_api_response()` parsers (strict) and the
|
||||
@@ -348,6 +356,7 @@ through a **constructor collaborator**. Decide per piece of state:
|
||||
| fetched separately, with its own lifecycle/expiry (e.g. an entitlement or package list loaded after login) | a **small dedicated collaborator** (`JoynEntitlements`), owned by the provider | keyword-only extra `entitlements=` on every manager that needs it |
|
||||
| a cache | a plain **dict owned by the provider** | keyword-only extra, borrowed by reference |
|
||||
| a pure helper (id parsing, header building, URL building) | a **module-level function** or the `Config` | import it |
|
||||
| **client identity** — who *this installation* is, independent of any account (device id, install uuid) | the **authenticator + settings manager**; persisted at creation; **deliberately never cleared with the token** | read-only `self.auth.<attr>` (e.g. `auth.device_id`) |
|
||||
| needed by the **settings UI only** | the provider (thin delegate to the session) | — (managers never read it) |
|
||||
| read by a **legacy mixin through `self`** (e.g. `ProviderAuthMixin` reads `self.authenticator.get_bearer_token()`; shared with every v1 provider) | **stays on the provider — do not delete it** | managers do **not** use it and do not call the mixin; they use `self.auth` and build headers from `Config` |
|
||||
|
||||
@@ -358,6 +367,16 @@ migration then has to be done twice. If you cannot decide between "part of the
|
||||
session" and "separate type": derived from the token ⇒ session; has its own
|
||||
fetch/refresh ⇒ separate type.
|
||||
|
||||
**Client identity is a third category.** It is not account state (which
|
||||
`invalidate()` clears) and not a cache (which the `on_invalidate` callback clears on a
|
||||
credential change). It is created once, persisted at creation, and survives both —
|
||||
clearing it with the token makes the server see a new device after every logout.
|
||||
"Caches to clear on credential change" (§14, the brief) has no line for it because
|
||||
the concept had no name before. Corollary: if it is ever stale or wrong, the damage is
|
||||
silent (HRTi: a stale device id made EPG return `[]` for as long as nobody looked, §8),
|
||||
so every persisted identity value needs both getter **and** setter on the storage
|
||||
layer (§12.1b).
|
||||
|
||||
**Dead dependencies.** A declared keyword-only extra that no method reads is
|
||||
deleted, including its comment. An extra that is passed "for later" is a
|
||||
back-reference candidate nobody has reviewed.
|
||||
@@ -478,8 +497,21 @@ class YourManager(ChannelManager): # or Vod/Epg/Recordings/Favorite
|
||||
it for the programme at `start_time`. A provider-native window in `**kw`
|
||||
(`backwards=`/`forwards=` hours, movetv) is fine; ignore unknown kwargs such as
|
||||
`limit=` knowingly and say so in the docstring.
|
||||
* **Details endpoints that need a parent id.** `get_program_details(program_id)` hands
|
||||
you one id; some upstream details endpoints need more (HRTi `GetEpgDetails` wants
|
||||
`ChannelReferenceId` **and** `ReferenceId`). Derive the parent from the grid parse:
|
||||
keep a hint map `program_id → parent id` filled while parsing grids, make it
|
||||
self-healing (a details answer that names its channel corrects the map), and document
|
||||
the empty-id fallback used when no hint exists. Expose the **raw `Result`** of the
|
||||
details call next to the ABC method (e.g. `get_program_details_raw`) for
|
||||
provider-specific fields the model cannot hold (HRTi catchup reads `FileName*` from
|
||||
it).
|
||||
* **`epg_event_id` vs `program_id`** (models docstring) works as specified: a provider
|
||||
with a single per-broadcast id (HRTi) populates `program_id` and leaves
|
||||
`epg_event_id = None`.
|
||||
* A failed request RAISES; "no programmes" (`success=false`, empty list) is `[]`
|
||||
(decision rule in §8).
|
||||
(decision rule in §8, **including its boundary**: session/identity-state errors
|
||||
always raise, whatever the endpoint's classification).
|
||||
|
||||
### 6.4 `RecordingsManager`
|
||||
|
||||
@@ -645,6 +677,13 @@ Names marked above beyond the ones in the shipped base were taken from
|
||||
docstrings and imports — **(unverified)**: check `base/errors.py` for the exact
|
||||
constructors (the templates assume `Error("message")`).
|
||||
|
||||
**Still open after three migrations.** Two standing hedges have never been verified:
|
||||
(a) the HTTP manager's behaviour on 4xx/5xx (raise vs. return — HRTi's `api.py` still
|
||||
handles both shapes), (b) the `base.errors` constructor kwargs (`url=` on `AuthError`
|
||||
is hedged in two places). The hedges work, but each migration copies them. Pin both
|
||||
against the real code before the next provider (§17) and replace the hedges with the
|
||||
verified behaviour.
|
||||
|
||||
### Compatibility on purpose
|
||||
|
||||
`ItemNotFoundError` is a `NotFoundError` **and** a `KeyError`;
|
||||
@@ -682,6 +721,13 @@ KeyError` / `except RuntimeError` in the operations layer keep working.
|
||||
source that may have expired) returns `[]` / `None` with a warning. Record the
|
||||
choice per endpoint in the brief; it is the single most common silent behaviour
|
||||
change of a migration.
|
||||
* **Boundary of that rule: data-availability errors only.** Required/optional decides
|
||||
what "no data" means. It never applies to **session- or identity-state errors**
|
||||
(token rejected, device session unknown to the server, stale client id): those raise
|
||||
typed errors (`AuthError` after the recovery ladder, §9) **regardless of how the
|
||||
endpoint is classified**. HRTi's EPG was `required=False`; a stale device id made it
|
||||
return `[]` — indistinguishable from "no programmes" — for however long nobody
|
||||
noticed. Test: a session-state failure on an optional endpoint raises.
|
||||
* **`operation=` on `http_manager.post/get`** selects the proxy/timeout scope (`"api"`,
|
||||
`"auth"`, `"manifest"`, `"license"` in the template). v1 providers pass ad-hoc
|
||||
names (`"live_manifest"`, `"token_validate"`) and omit it elsewhere (default).
|
||||
@@ -710,10 +756,14 @@ Two supported shapes. Both give managers an `auth` that satisfies `AuthProtocol`
|
||||
### Rules for both
|
||||
|
||||
* **Lazy**: no network in `__init__`. The first caller that needs a token logs in.
|
||||
* **One login for concurrent callers**: lock + re-check after acquiring it.
|
||||
* **One login for concurrent callers**: lock + re-check after acquiring it (the
|
||||
re-check only dedupes when the repair rotates the identifier — see the recovery
|
||||
ladder below).
|
||||
* **Typed errors**, never `None`/`[]`.
|
||||
* `invalidate()` clears memory **and persisted storage** (otherwise a restart
|
||||
resurrects the old session; this bit `set_user_credentials`).
|
||||
resurrects the old session; this bit `set_user_credentials`). **Client identity
|
||||
(device id / install uuid) is not part of that**: owned by the authenticator +
|
||||
settings manager, persisted at creation, never cleared with the token (§5.1).
|
||||
* The token's own expiry (`is_expired`, fixed 300 s buffer in `BaseAuthToken`) is
|
||||
the single truth — **a new provider must not add a second buffer constant.**
|
||||
**Migrating a provider that already has one** (look for `expires_in - 1800`-style
|
||||
@@ -724,7 +774,9 @@ Two supported shapes. Both give managers an `auth` that satisfies `AuthProtocol`
|
||||
in **one** documented place (token creation) unless a device test proves it
|
||||
redundant, and record the decision in `MIGRATION_BRIEF.md`.
|
||||
* Never log credentials or the dict from `to_auth_payload()`; verify the HTTP
|
||||
layer does not log request bodies at DEBUG.
|
||||
layer does not log request bodies at DEBUG. **Token fragments count** (HRTi v1
|
||||
`auth.py` printed three DEBUG lines with token fragments). Nothing scans for this
|
||||
automatically — grep during migration (§12.1b).
|
||||
* Token transport is provider-specific: header (Allente, template), query string
|
||||
or body (simpli: `with_token()`/`auth_body()` helpers, token-free headers).
|
||||
Keep `build_headers()` token-free if the token does not belong in headers.
|
||||
@@ -793,7 +845,7 @@ three per call). A session adapter for such a provider:
|
||||
|
||||
* validates **once per process** (the first `authenticate()` after start, which also
|
||||
validates a persisted token) and then trusts the token;
|
||||
* on HTTP **401** runs `recover()`: refresh the token, else a full login with
|
||||
* on HTTP **401** (or the provider's body-level equivalent, next subsection) runs `recover()`: refresh the token, else a full login with
|
||||
`force_refresh=True`, then **exactly one** retry of the request (a shared
|
||||
`api_post` helper does this for every manager; a second 401 raises `AuthError`);
|
||||
* does not remove the validation silently: record it in the brief (token decision
|
||||
@@ -805,6 +857,36 @@ The template adapter assumes `current_token`, `has_user_credentials()` and
|
||||
before wrapping, or use the provider authenticator's own getters (movetv:
|
||||
`get_current_token()`).
|
||||
|
||||
### Server-side invalidation and the recovery ladder
|
||||
|
||||
Validity can end on the server without an HTTP 401 and without any expiry in the
|
||||
token. The signal may sit in the **body of a 200**: HRTi's `ErrorCode 4` means "device
|
||||
session unknown to the server" while the token itself is perfectly valid. The
|
||||
movetv "server-validated token" pattern generalizes to a ladder:
|
||||
|
||||
* Detect invalidation in the **one** request helper every manager goes through
|
||||
(`api_post`-style), with a provider-defined predicate covering the HTTP status
|
||||
**and** body codes. Never in individual managers.
|
||||
* Repair is a **recovery ladder**: each rung **at most once** per request, **cheapest
|
||||
repair first**, then exactly one retry of the original request:
|
||||
1. *targeted repair* (e.g. re-register the device/session) — preserves a valid token;
|
||||
2. *full refresh* (token refresh, or login with `force_refresh=True`) — also works
|
||||
(HRTi: login re-registers the device as a side effect) but throws a valid token
|
||||
away;
|
||||
3. a surviving failure → `AuthError`.
|
||||
The movetv validate-once + 401 recovery is the single-rung special case.
|
||||
* Session/identity errors raise on every endpoint, optional ones included (§8 boundary).
|
||||
* **Concurrency.** Repair under a lock with a re-check after acquiring it: the second
|
||||
caller compares the identifier it failed with against the current one and skips the
|
||||
repair if it changed. That dedup **only works when the repair rotates the
|
||||
identifier.** HRTi's `RegisterDevice` (re-)creates the server record for the
|
||||
`DeviceSerial` you supply — no rotation — so id comparison can never dedupe and a
|
||||
**short time window** ("repaired within the last N seconds → just retry") is
|
||||
required instead. Record in the brief which of the two applies.
|
||||
* Tests: body code on a 200 → targeted repair once + one retry; targeted repair fails
|
||||
→ full refresh; both fail → `AuthError` with bounded call counts; N concurrent
|
||||
callers → one repair.
|
||||
|
||||
### Credentials API on the provider (OPTIONAL — only if v1 has it)
|
||||
|
||||
**A migration preserves the v1 public surface; it never adds to it.** Of the two
|
||||
@@ -1021,6 +1103,29 @@ Fill this table for your provider:
|
||||
| `provider_name`, directory, class name, credentials key — all equal? | §4 |
|
||||
| Does anything outside the provider read its attributes (`bearer_token`, `_profile`, `channel_manager`, …)? | compatibility delegates / call-site fixes |
|
||||
|
||||
### 12.1b Persisted-state and logging checks (before converting anything)
|
||||
|
||||
Two checks the pack does not do yet (§17); each cost a migration a surprise.
|
||||
|
||||
**(a) Getter *and* setter for every persisted value.** For each piece of state the
|
||||
provider persists (device id, tokens, profile, …) verify that both halves exist on the
|
||||
storage layer. HRTi had `get_device_id` with no `set_device_id`; it was found
|
||||
mid-migration and degraded persistence to a warning.
|
||||
|
||||
```bash
|
||||
# for each persisted key K: both lines must be found
|
||||
grep -rn --include='*.py' -E 'def get_<K>\b' base streaming_providers
|
||||
grep -rn --include='*.py' -E 'def set_<K>\b' base streaming_providers
|
||||
```
|
||||
|
||||
**(b) Credential/token logging.** §9 forbids it; nothing scans for it.
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.py' -iE 'log(ger)?\.(debug|info|warning|error).*(token|password|secret|bearer|authorization|serial)' providers/<key>
|
||||
```
|
||||
|
||||
Every hit is read, not skimmed: truncated or masked fragments (`token[:8]`) count.
|
||||
|
||||
### 12.2 v1 → v2 mapping (what to delete, what to keep)
|
||||
|
||||
| In the v1 provider | In v2 |
|
||||
@@ -1069,6 +1174,21 @@ Fill this table for your provider:
|
||||
8. Run the external-reference grep (§12.6), then the device checklist (§12.7).
|
||||
9. Remove the delegates once nothing uses them (separate commit).
|
||||
|
||||
### 12.3b Rules for the migration process itself
|
||||
|
||||
* **Staged capability registration is fine** (channels first, EPG in step 2) — but the
|
||||
"EPG deliberately NOT registered yet" module docstring and the `# _build_epg(): step 2`
|
||||
placeholder must flip **in the same commit** as the factory. For exactly one step the
|
||||
HRTi module docstring lied about the provider's capabilities (Trap 26).
|
||||
* **How code is handed to a migrator.** Insertion blocks with `# ADAPT:` comments are a
|
||||
bug vector: the block that was pasted into HRTi shipped `self.authenticator` (an
|
||||
attribute `HRTiSession` does not have) with the ADAPT comments still in the code —
|
||||
the "migration done twice" failure of §5.1, in migration form. Rule: **output
|
||||
complete files when the file is small enough; otherwise the block must name exact
|
||||
anchor symbols (what to replace / insert after) and every adaptation must be
|
||||
mechanical** (a rename the reader can grep, never a decision). Decisions belong in the
|
||||
brief, not in a comment. Gate: `grep -rn "ADAPT" providers/<key>` is empty.
|
||||
|
||||
### 12.4 What becomes reachable that never was
|
||||
|
||||
If the v1 provider did not define `get_epg*`, `get_recordings`/`delete_recording`,
|
||||
@@ -1102,6 +1222,9 @@ catchup window, the EPG flag network call).
|
||||
| Channel fields removed from `to_dict()` (no Channel subclass) | internals stay in the manager | — |
|
||||
| One failing channel no longer empties the EPG grid (if you override it, §6.3) | resilience | keep the default |
|
||||
| Capability exists in the code but is deliberately **not registered** (stub parsers) | a registered capability that crashes is worse than a missing one | flip the provider flag when the body exists |
|
||||
| EPG goes through the session; a failed login now blocks EPG with typed errors | v1 EPG `_get_headers` read `authenticator._current_token.access_token` directly (a private-attribute back-reference, the shape §5.1 bans) and fired requests with whatever token state was lying around | — (accepted) |
|
||||
| Session/identity-state errors raise on optional endpoints too | the required/optional rule covers data availability only (§8) | — |
|
||||
| Server-side invalidation (status or body code) repaired by the recovery ladder | §9; v1 either failed silently or relied on expiry | — |
|
||||
|
||||
### 12.6 External references (needs the whole repo — the migration pack section 5 does this)
|
||||
|
||||
@@ -1207,7 +1330,9 @@ providers can be mixed freely.
|
||||
* **TTL cache** pattern used by simpli/Allente: `(value, timestamp)` tuples, TTL in
|
||||
`constants.py` (`PLAYOUT_CACHE_TTL = 5.0`; manifest + DRM share one request).
|
||||
* **Clear caches when credentials change** (`set_user_credentials` →
|
||||
`_playout_cache.clear()`): ids/stream ids may be user- or token-specific.
|
||||
`_playout_cache.clear()`): ids/stream ids may be user- or token-specific. **Client
|
||||
identity (device id / install uuid) is not on that list** — it is neither account
|
||||
state nor a cache and survives a credential change (§5.1).
|
||||
* The route cache in `ManagedProvider` is bounded (`ROUTE_CACHE_SIZE = 512`) and locked.
|
||||
* A shared thread-safe TTL helper does not exist yet (§17) — keep caches small and
|
||||
per-provider.
|
||||
@@ -1397,6 +1522,21 @@ Things that already cost time. Each is covered by a test or the contract unless
|
||||
55. A registered capability whose parsers/bodies are stubs — hide it instead (§12.2).
|
||||
56. Same-named files uploaded separately overwrite each other; packs written inside the
|
||||
scanned tree report themselves as external users (§12.0b).
|
||||
57. Server-side session invalidation signalled in a 200 body (HRTi `ErrorCode 4`) while
|
||||
the token is valid — status-code-only detection misses it; use the ladder (§9).
|
||||
58. The required/optional rule applied to session/identity errors: `required=False`
|
||||
hid a stale device id behind `[]` (§8).
|
||||
59. Client identity cleared with the token, or treated as a cache (§5.1).
|
||||
60. Re-check-after-lock dedup on a repair that does not rotate the identifier — it
|
||||
never dedupes; use a time window (§9).
|
||||
61. `# ADAPT:` comments and non-existent attributes (`self.authenticator` on a session)
|
||||
surviving in pasted insertion blocks (§12.3b).
|
||||
62. Persisted state with a getter but no setter on the storage layer — persistence
|
||||
silently degrades to a warning (§12.1b).
|
||||
63. Token fragments in DEBUG log lines (§9, §12.1b).
|
||||
64. Staged registration where the docstring/placeholder flips in a later commit than
|
||||
the factory (§12.3b).
|
||||
65. A details endpoint that needs a parent id fed only `program_id` (§6.3).
|
||||
|
||||
---
|
||||
|
||||
@@ -1433,12 +1573,18 @@ Known gaps in the base, so you don't hunt for them:
|
||||
presence, (c) unused constructor params, (d) `except Exception` around `self.auth.*`
|
||||
calls, (e) literal URLs outside `constants.py`, (f) two config constructions in one
|
||||
provider, (g) docstring claims (`provider wires`, `see provider.py`) without
|
||||
matching code.
|
||||
* `optional/session_adapter.py` updated to the rules in §9.
|
||||
matching code, (h) getter/setter pairs for every persisted key on the storage layer
|
||||
(§12.1b a), (i) credential/token logging (§12.1b b), (j) leftover `# ADAPT` comments
|
||||
(§12.3b).
|
||||
* **Pin the two standing (unverified) hedges** (§8): the HTTP manager's 4xx/5xx
|
||||
behaviour and the `base.errors` constructor kwargs; then delete the hedges from
|
||||
`api.py` and the templates.
|
||||
* `optional/session_adapter.py` updated to the rules in §9 (including the recovery
|
||||
ladder and the time-window dedup).
|
||||
* `tests/conftest.py` with `FakeHttp`, `FakeAuthenticator`, fake clock, `env` (§15).
|
||||
|
||||
**Base changes — every one must be additive with an unchanged default and pass the
|
||||
contract test for ALL existing providers (Allente, simpli, Magenta2, Joyn, movetv)
|
||||
contract test for ALL existing providers (Allente, simpli, Magenta2, Joyn, movetv, HRTi)
|
||||
before it is merged. Two are implemented as patches (below); the rest are proposals:**
|
||||
|
||||
* **Implemented as patches, opt-in, defaults unchanged** (`managed_provider.py`,
|
||||
@@ -1451,7 +1597,7 @@ before it is merged. Two are implemented as patches (below); the rest are propos
|
||||
* **Deliberately NOT added:** an `EpgManager.get_entry_at()` helper. Only movetv
|
||||
needs "the programme at a timestamp"; its matching rules (containment, tolerance)
|
||||
are provider-specific. Add it when a second provider needs the same rules.
|
||||
* One shared session adapter with `recover()` and `on_invalidate` (backwards
|
||||
* One shared session adapter with `recover()` (the §9 ladder) and `on_invalidate` (backwards
|
||||
compatible with the template adapter's public methods) plus the shared `api_post`
|
||||
helper (401 → one recovery → one retry, both HTTP-manager error shapes) — as a new
|
||||
module; no provider is forced to switch.
|
||||
@@ -1497,7 +1643,14 @@ before it is merged. Two are implemented as patches (below); the rest are propos
|
||||
- [ ] `success=false` / empty-answer decision recorded per endpoint
|
||||
- [ ] No `Channel` subclass; internals live in the manager; external users of removed `to_dict()` keys checked
|
||||
- [ ] No provider-local type shares a name with a base type
|
||||
- [ ] Token validation model recorded (expiry-based vs. server-validated) and tested
|
||||
- [ ] Token validation model recorded (expiry-based vs. server-validated; invalidation signal: status and/or body code) and tested, including the recovery ladder (each rung at most once, then `AuthError`) and the concurrency dedup (rotated id vs. time window)
|
||||
- [ ] Session/identity-state errors raise on every endpoint, optional ones included (§8)
|
||||
- [ ] Client identity (device id / install uuid) owned by authenticator + settings manager, persisted at creation, never cleared with the token (§5.1)
|
||||
- [ ] Every persisted key has getter **and** setter on the storage layer (§12.1b)
|
||||
- [ ] No token/credential fragment in any log line (grep, §12.1b)
|
||||
- [ ] No `# ADAPT` comment and no reference to a non-existent attribute left from pasted blocks (§12.3b)
|
||||
- [ ] Capability docstring/placeholder flipped in the same commit as its factory (§12.3b)
|
||||
- [ ] Details endpoints needing a parent id: hint map + documented fallback; raw result exposed if provider-specific fields are read (§6.3)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@ Public API:
|
||||
|
||||
from .provider import HRTiProvider
|
||||
|
||||
__all__ = ["HRTiProvider"]
|
||||
__all__ = ["HRTiProvider"]
|
||||
|
||||
@@ -201,4 +201,4 @@ def api_post(
|
||||
)
|
||||
logger.debug(f"HRTi optional endpoint {endpoint_key}: [{code}] {description}")
|
||||
return None
|
||||
return data.get("Result")
|
||||
return data.get("Result")
|
||||
|
||||
@@ -22,9 +22,10 @@ import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
from ...base.errors import AuthError, ProviderError, ServerError
|
||||
from ...base.utils.logger import logger
|
||||
from .api import DEVICE_SESSION_INVALID_CODE, typed_error_for
|
||||
@@ -85,9 +86,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""Get headers specifically for authentication endpoint"""
|
||||
if not self._ip_address:
|
||||
self._get_ip_address()
|
||||
|
||||
ip_address = self._get_ip_address() # cached; "0.0.0.0" on failure (never None)
|
||||
device_id = self.get_device_id()
|
||||
|
||||
# Special headers for authentication endpoint with /signin referer
|
||||
@@ -97,7 +96,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
"Content-Type": "application/json",
|
||||
"deviceid": device_id,
|
||||
"devicetypeid": self.config.device_reference_id,
|
||||
"ipaddress": self._ip_address,
|
||||
"ipaddress": ip_address,
|
||||
"operatorreferenceid": self.config.operator_reference_id,
|
||||
"origin": self.config.base_website,
|
||||
"referer": f"{self.config.base_website}/signin",
|
||||
@@ -105,11 +104,11 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
|
||||
return headers
|
||||
|
||||
def _get_api_headers(self, bearer_token: str = None, referer: str = None) -> Dict[str, str]:
|
||||
def _get_api_headers(
|
||||
self, bearer_token: Optional[str] = None, referer: Optional[str] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get headers for regular API endpoints with proper session support"""
|
||||
if not self._ip_address:
|
||||
self._get_ip_address()
|
||||
|
||||
ip_address = self._get_ip_address() # cached; "0.0.0.0" on failure (never None)
|
||||
device_id = self.get_device_id()
|
||||
|
||||
if referer is None:
|
||||
@@ -122,7 +121,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
"Content-Type": "application/json",
|
||||
"deviceid": device_id,
|
||||
"devicetypeid": self.config.device_reference_id,
|
||||
"ipaddress": self._ip_address,
|
||||
"ipaddress": ip_address,
|
||||
"operatorreferenceid": self.config.operator_reference_id,
|
||||
"origin": self.config.base_website,
|
||||
"referer": referer,
|
||||
@@ -156,7 +155,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
|
||||
def _create_token_from_response(self, response_data: Dict[str, Any]) -> HRTiAuthToken:
|
||||
"""Create HRTi-specific token from API response"""
|
||||
result = response_data.get("Result", {})
|
||||
result = response_data.get("Result") or {}
|
||||
|
||||
# Debug the response structure
|
||||
logger.debug(f"GrantAccess response Result keys: {list(result.keys())}")
|
||||
@@ -298,15 +297,11 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
def _perform_grant_access(self) -> Dict[str, Any]:
|
||||
"""Perform grant access authentication with proper headers and debugging"""
|
||||
try:
|
||||
# Ensure we have IP and device ID
|
||||
if not self._ip_address:
|
||||
self._get_ip_address()
|
||||
|
||||
device_id = self.get_device_id()
|
||||
|
||||
logger.debug(f"Performing grant access with username: {self.credentials.username}")
|
||||
logger.debug(f"Using device ID: {device_id}")
|
||||
logger.debug(f"Using IP address: {self._ip_address}")
|
||||
logger.debug(f"Using IP address: {self._get_ip_address()}")
|
||||
|
||||
# Use auth-specific headers with /signin referer
|
||||
headers = self._get_auth_headers()
|
||||
@@ -359,7 +354,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
else:
|
||||
if isinstance(e, ProviderError):
|
||||
raise
|
||||
raise ServerError(f"HRTi login failed: {e}") from e
|
||||
raise typed_error_for(e, self.config.api_endpoints.get("grant_access")) from e
|
||||
|
||||
def _register_device(self, strict: bool = False) -> bool:
|
||||
"""
|
||||
@@ -406,7 +401,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
payload = {
|
||||
"DeviceSerial": self._device_id,
|
||||
"DeviceReferenceId": self.config.device_reference_id,
|
||||
"IpAddress": self._ip_address,
|
||||
"IpAddress": self._get_ip_address(),
|
||||
"ConnectionType": self.config.connection_type,
|
||||
"ApplicationVersion": self.config.application_version,
|
||||
"DrmId": self._device_id,
|
||||
@@ -569,16 +564,23 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
def get_user_id(self) -> str:
|
||||
return self._user_id or ""
|
||||
|
||||
def _stored_credentials(self) -> Any:
|
||||
"""Credentials from the settings manager (flat key, country=None)."""
|
||||
try:
|
||||
return self.settings_manager.get_provider_credentials(self.provider_name, self.country)
|
||||
except TypeError: # older settings manager without the country parameter
|
||||
return self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
|
||||
def _has_valid_user_credentials(self) -> bool:
|
||||
"""Stored OR current credentials are valid user (username/password) credentials."""
|
||||
return any(
|
||||
isinstance(creds, UserPasswordCredentials) and creds.validate()
|
||||
for creds in (self._stored_credentials(), self.credentials)
|
||||
)
|
||||
|
||||
def has_user_credentials(self) -> bool:
|
||||
"""True when real (non-anonymous) user credentials are stored or set."""
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
|
||||
try:
|
||||
stored = self.settings_manager.get_provider_credentials(
|
||||
self.provider_name, self.country
|
||||
)
|
||||
except TypeError:
|
||||
stored = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
stored = self._stored_credentials()
|
||||
for creds in (stored, self.credentials):
|
||||
if (
|
||||
isinstance(creds, UserPasswordCredentials)
|
||||
@@ -592,11 +594,11 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
self,
|
||||
content_type: str,
|
||||
content_ref_id: str,
|
||||
content_drm_id: str = None,
|
||||
video_store_ids: list = None,
|
||||
channel_id: str = None,
|
||||
start_time: str = None,
|
||||
end_time: str = None,
|
||||
content_drm_id: Optional[str] = None,
|
||||
video_store_ids: Optional[List[str]] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
start_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Authorize a playback session. Raises typed errors (AuthError on 401,
|
||||
ServerError otherwise); returns the raw ``Result`` dict -- the caller
|
||||
@@ -665,7 +667,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
)
|
||||
return session_result
|
||||
|
||||
def report_session_event(self, session_id: str, channel_id: str = None) -> bool:
|
||||
def report_session_event(self, session_id: str, channel_id: Optional[str] = None) -> bool:
|
||||
"""Report session event (like play start)"""
|
||||
try:
|
||||
bearer_token = self._current_token.access_token if self._current_token else ""
|
||||
@@ -716,30 +718,13 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
if token.user_id == HRTiDefaults.ANONYMOUS_USERNAME:
|
||||
return TokenAuthLevel.ANONYMOUS
|
||||
|
||||
# Check if we have user credentials available
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
|
||||
# Check stored credentials first
|
||||
try:
|
||||
stored_creds = self.settings_manager.get_provider_credentials(
|
||||
self.provider_name, self.country
|
||||
)
|
||||
except TypeError:
|
||||
stored_creds = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
|
||||
has_user_creds = (
|
||||
isinstance(stored_creds, UserPasswordCredentials) and stored_creds.validate()
|
||||
)
|
||||
|
||||
# Check current credentials
|
||||
if not has_user_creds:
|
||||
has_user_creds = (
|
||||
isinstance(self.credentials, UserPasswordCredentials)
|
||||
and self.credentials.validate()
|
||||
)
|
||||
|
||||
# If we have user credentials and token has a real user_id, it's user authenticated
|
||||
if has_user_creds and token.user_id and token.user_id != HRTiDefaults.ANONYMOUS_USERNAME:
|
||||
# If we have user credentials (stored or current) and the token has a real
|
||||
# user_id, it's user authenticated
|
||||
if (
|
||||
self._has_valid_user_credentials()
|
||||
and token.user_id
|
||||
and token.user_id != HRTiDefaults.ANONYMOUS_USERNAME
|
||||
):
|
||||
return TokenAuthLevel.USER_AUTHENTICATED
|
||||
|
||||
# Check if using anonymous credentials
|
||||
@@ -769,28 +754,8 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
|
||||
# Check if token is anonymous and we have user credentials
|
||||
if token.auth_level == TokenAuthLevel.ANONYMOUS:
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
|
||||
# Check if we have stored user credentials
|
||||
try:
|
||||
stored_creds = self.settings_manager.get_provider_credentials(
|
||||
self.provider_name, self.country
|
||||
)
|
||||
except TypeError:
|
||||
stored_creds = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
|
||||
has_stored_user_creds = (
|
||||
isinstance(stored_creds, UserPasswordCredentials) and stored_creds.validate()
|
||||
)
|
||||
|
||||
# Check current credentials
|
||||
has_current_user_creds = (
|
||||
isinstance(self.credentials, UserPasswordCredentials)
|
||||
and self.credentials.validate()
|
||||
)
|
||||
|
||||
# Upgrade if we have any user credentials
|
||||
return has_stored_user_creds or has_current_user_creds
|
||||
# Upgrade if we have any user credentials (stored or current)
|
||||
return self._has_valid_user_credentials()
|
||||
|
||||
return False
|
||||
|
||||
@@ -806,11 +771,12 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
current_token = self.authenticate(force_refresh=force_refresh)
|
||||
|
||||
# Check if upgrade is needed/requested
|
||||
should_upgrade = force_upgrade or self.should_upgrade_token(current_token)
|
||||
upgrade_wanted = self.should_upgrade_token(current_token)
|
||||
should_upgrade = force_upgrade or upgrade_wanted
|
||||
|
||||
if should_upgrade:
|
||||
logger.info(
|
||||
f"HRTi token upgrade triggered (force={force_upgrade}, should_upgrade={self.should_upgrade_token(current_token)})"
|
||||
f"HRTi token upgrade triggered (force={force_upgrade}, should_upgrade={upgrade_wanted})"
|
||||
)
|
||||
|
||||
# Store original credentials at the beginning
|
||||
@@ -818,12 +784,7 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
|
||||
try:
|
||||
# Get user credentials from settings manager
|
||||
try:
|
||||
user_creds = self.settings_manager.get_provider_credentials(
|
||||
self.provider_name, self.country
|
||||
)
|
||||
except TypeError:
|
||||
user_creds = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
user_creds = self._stored_credentials()
|
||||
|
||||
if not user_creds or not user_creds.validate():
|
||||
logger.debug("No valid user credentials available for upgrade")
|
||||
@@ -949,4 +910,4 @@ class HRTiAuthenticator(BaseAuthenticator):
|
||||
"get_device_id-mirroring signature (provider, country, device_id)) "
|
||||
"-- the current device id is not persisted; ErrorCode-4 recovery "
|
||||
"stays reactive. Add the setter to make it stick across restarts."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ defaults below are used (they equal the live values).
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, ClassVar, Dict, Optional
|
||||
|
||||
from ...base.errors import ConfigurationError
|
||||
from ...base.utils.logger import logger
|
||||
@@ -42,7 +42,7 @@ class HRTiDefaults:
|
||||
FALLBACK_MERCHANT = "aviion2"
|
||||
|
||||
# Endpoint path templates; base URLs come from the bootstrap
|
||||
API_ENDPOINT_PATHS = {
|
||||
API_ENDPOINT_PATHS: ClassVar[Dict[str, str]] = {
|
||||
"get_ip": "/getIPAddress",
|
||||
"grant_access": "/GrantAccess",
|
||||
"channels": "/GetChannels",
|
||||
@@ -174,6 +174,13 @@ class HRTiConfig:
|
||||
cfg_resp.raise_for_status()
|
||||
self.update_from_api(env_resp.json(), cfg_resp.json())
|
||||
self.loaded_from_fallback = False
|
||||
if not self.hsapi_base_url or not self.web_api_url:
|
||||
# Bootstrap answered but lacks apiUrl / webApiUrl: fill ONLY the
|
||||
# gaps from the defaults (otherwise endpoint() raises a PERMANENT
|
||||
# ConfigurationError for every later call).
|
||||
logger.warning("HRTi: bootstrap config incomplete; filling gaps from defaults")
|
||||
self._build_fallback_endpoints()
|
||||
self.loaded_from_fallback = True
|
||||
logger.debug("HRTi environment configuration loaded")
|
||||
except Exception as exc: # deliberate: bootstrap failure -> documented defaults
|
||||
logger.warning(f"HRTi: environment config unavailable ({exc}); using defaults")
|
||||
|
||||
@@ -33,8 +33,9 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
from ...base.errors import ServerError
|
||||
from ...base.managers.epg import EpgManager
|
||||
from ...base.models.epg_models import (
|
||||
EPGEntry,
|
||||
@@ -67,7 +68,7 @@ class HRTiEpgManager(EpgManager):
|
||||
|
||||
# HRTi category label -> DVB-SI genre (ETSI EN 300 468); labels are
|
||||
# upper-cased before the lookup. Same map as v1.
|
||||
_CATEGORY_GENRES = {
|
||||
_CATEGORY_GENRES: ClassVar[Dict[str, int]] = {
|
||||
"VIJESTI": EPGGenre.NEWSCURRENTAFFAIRS,
|
||||
"SPORT": EPGGenre.SPORTS,
|
||||
"GLAZBA": EPGGenre.MUSICBALLETDANCE,
|
||||
@@ -228,6 +229,8 @@ class HRTiEpgManager(EpgManager):
|
||||
) -> Dict[str, List[EPGEntry]]:
|
||||
"""ONE GetProgramme call; every requested id is present in the dict."""
|
||||
ids = [str(cid) for cid in channel_ids if cid]
|
||||
if not ids:
|
||||
return {}
|
||||
start_time, end_time = self._resolve_range(start_time, end_time)
|
||||
payload = {
|
||||
"ChannelReferenceIds": ids,
|
||||
@@ -260,9 +263,15 @@ class HRTiEpgManager(EpgManager):
|
||||
return grid
|
||||
if not result:
|
||||
return grid
|
||||
if not isinstance(result, list):
|
||||
raise ServerError(
|
||||
f"HRTi EPG: unexpected GetProgramme result type {type(result).__name__}"
|
||||
)
|
||||
|
||||
total = 0
|
||||
for channel_data in result:
|
||||
if not isinstance(channel_data, dict):
|
||||
continue
|
||||
channel_id = channel_data.get("ReferenceID")
|
||||
if not channel_id:
|
||||
continue
|
||||
@@ -378,7 +387,7 @@ class HRTiEpgManager(EpgManager):
|
||||
self._remember_channel(program_id, str(channel_ref))
|
||||
|
||||
genres: List[str] = []
|
||||
for category in [raw.get("Category")] + list(raw.get("AdditionalCategories") or []):
|
||||
for category in [raw.get("Category"), *(raw.get("AdditionalCategories") or [])]:
|
||||
if category and category not in genres:
|
||||
genres.append(category)
|
||||
|
||||
@@ -476,4 +485,4 @@ class HRTiEpgManager(EpgManager):
|
||||
"""Programme -> channel hint for GetEpgDetails (advisory, see __init__)."""
|
||||
if len(self._channel_by_epg_id) >= self._CHANNEL_HINT_CAP:
|
||||
self._channel_by_epg_id.clear()
|
||||
self._channel_by_epg_id[epg_reference_id] = channel_id
|
||||
self._channel_by_epg_id[epg_reference_id] = channel_id
|
||||
|
||||
@@ -100,8 +100,9 @@ class HRTiProvider(ManagedProvider):
|
||||
|
||||
def _clear_caches(self) -> None:
|
||||
self._playout_cache.clear()
|
||||
if self.channels is not None:
|
||||
self.channels.clear_caches()
|
||||
clear_channel_caches = getattr(self.channels, "clear_caches", None)
|
||||
if callable(clear_channel_caches): # None (no live) or the pre-init legacy list: skip
|
||||
clear_channel_caches()
|
||||
# NOTE: the EPG manager's programme->channel hint map is schedule
|
||||
# data, not account-specific state -- deliberately NOT cleared here.
|
||||
|
||||
@@ -136,4 +137,4 @@ class HRTiProvider(ManagedProvider):
|
||||
auth=self.auth,
|
||||
country=self.country,
|
||||
config=self.hrti_config,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -264,4 +264,4 @@ class HRTiSession:
|
||||
raise
|
||||
raise typed from exc
|
||||
self._last_reregister_at = now
|
||||
self._clear_failure()
|
||||
self._clear_failure()
|
||||
|
||||
@@ -349,7 +349,7 @@ class HRTiVodManager(VodManager):
|
||||
series_title=item.get("SeriesName"),
|
||||
trailer_url=item.get("TrailerUrl"),
|
||||
is_highlight=False,
|
||||
content_type="SERIES" if (item.get("Type") or "").lower() == "episode" else "MOVIE",
|
||||
content_type="SERIES" if str(item.get("Type") or "").lower() == "episode" else "MOVIE",
|
||||
manifest_script=item.get("FileName") or None,
|
||||
)
|
||||
except _PARSE_ERRORS as exc:
|
||||
|
||||
Reference in New Issue
Block a user