More fix hrti

This commit is contained in:
Nirvana
2026-10-16 21:51:41 +02:00
parent d327136be5
commit 1c68080392
9 changed files with 243 additions and 112 deletions
@@ -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")
+48 -87
View File
@@ -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: