mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-05 23:42:57 +02:00
262 lines
8.9 KiB
Python
262 lines
8.9 KiB
Python
# streaming_providers/base/errors.py
|
|||
|
|
"""
|
||
|
|
Shared provider error hierarchy.
|
||
|
|
|
||
|
|
Rationale
|
||
|
|
---------
|
||
|
|
Every provider raises its own exception types for the same handful of
|
||
|
|
conditions: expired auth, geo-block, entitlement denial, content removed,
|
||
|
|
rate limits, server faults, playback restrictions, catchup-required items.
|
||
|
|
Downstream code (operations layer, Kodi plugin, UI) then has to special-case
|
||
|
|
per provider.
|
||
|
|
|
||
|
|
This module provides one hierarchy that all providers can subclass. Providers
|
||
|
|
MAY keep their own exception class names (existing callers can still catch
|
||
|
|
those); the only requirement is that the provider's classes inherit from the
|
||
|
|
appropriate base here.
|
||
|
|
|
||
|
|
This is a *convention*, not a mechanical enforcement. Nothing prevents a
|
||
|
|
provider from raising a bare Exception. But if a provider raises from this
|
||
|
|
hierarchy, callers get uniform handling for free.
|
||
|
|
|
||
|
|
Usage in a provider:
|
||
|
|
|
||
|
|
from ...base.errors import AuthError, GeoBlockError
|
||
|
|
|
||
|
|
class MyVodAuthError(AuthError): ...
|
||
|
|
class MyVodGeoBlockError(GeoBlockError): ...
|
||
|
|
|
||
|
|
Usage in a caller:
|
||
|
|
|
||
|
|
from ...base.errors import AuthError, GeoBlockError, ProviderError
|
||
|
|
|
||
|
|
try:
|
||
|
|
manifest = provider.get_manifest(content_id)
|
||
|
|
except AuthError:
|
||
|
|
... # refresh credentials / prompt for re-login
|
||
|
|
except GeoBlockError:
|
||
|
|
... # not available in your region
|
||
|
|
except ProviderError as e:
|
||
|
|
... # generic fallback; e.code may carry a provider-specific code
|
||
|
|
|
||
|
|
Error codes
|
||
|
|
-----------
|
||
|
|
Some providers (Discovery+) emit machine-readable codes alongside the
|
||
|
|
exception type. This base supports an optional `code` attribute. Providers
|
||
|
|
that don't use codes leave it None.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Any, Optional, Tuple
|
||
|
|
|
||
|
|
|
||
|
|
class ProviderError(Exception):
|
||
|
|
"""
|
||
|
|
Base class for all provider errors.
|
||
|
|
|
||
|
|
Every subclass accepts (message, *, status=None, url=None, code=None) and
|
||
|
|
stores them as attributes so callers can inspect without re-parsing the
|
||
|
|
message string.
|
||
|
|
|
||
|
|
Note on pickling / copy.deepcopy:
|
||
|
|
Subclasses are allowed to have different __init__ signatures
|
||
|
|
(e.g. ChannelNotFoundError(channel_id)) and to carry extra payload
|
||
|
|
attributes. __reduce__ below reconstructs via __new__ and restores
|
||
|
|
__dict__ as state, so it works regardless of the subclass's
|
||
|
|
constructor shape and preserves every attribute.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
message: str,
|
||
|
|
*,
|
||
|
|
status: Optional[int] = None,
|
||
|
|
url: Optional[str] = None,
|
||
|
|
code: Optional[str] = None,
|
||
|
|
) -> None:
|
||
|
|
super().__init__(message)
|
||
|
|
self.status = status
|
||
|
|
self.url = url
|
||
|
|
self.code = code
|
||
|
|
|
||
|
|
def __repr__(self) -> str:
|
||
|
|
msg = self.args[0] if self.args else ""
|
||
|
|
parts = [self.__class__.__name__, f"({msg!r}"]
|
||
|
|
if self.status is not None:
|
||
|
|
parts.append(f", status={self.status}")
|
||
|
|
if self.code is not None:
|
||
|
|
parts.append(f", code={self.code!r}")
|
||
|
|
parts.append(")")
|
||
|
|
return "".join(parts)
|
||
|
|
|
||
|
|
def __reduce__(self) -> Tuple[Any, ...]:
|
||
|
|
"""
|
||
|
|
Preserve ALL attributes across pickle / copy.deepcopy.
|
||
|
|
|
||
|
|
Returns a 3-tuple (callable, args, state). The annotation is
|
||
|
|
Tuple[Any, ...] because __reduce__ can return a 2-tuple or a
|
||
|
|
3-tuple depending on whether state is provided; the base
|
||
|
|
object.__reduce__ signature reflects this.
|
||
|
|
|
||
|
|
Exception.__reduce__ uses args only, which drops keyword-only fields
|
||
|
|
(status, url, code) and any subclass payload. Reconstruction goes
|
||
|
|
through __new__ so the subclass's __init__ is not called -- this
|
||
|
|
means subclass constructors with different signatures (e.g.
|
||
|
|
PlaybackRestrictedException(reason, error_code)) work fine, and
|
||
|
|
__dict__ is restored as-is.
|
||
|
|
"""
|
||
|
|
return (
|
||
|
|
_rebuild_provider_error,
|
||
|
|
(self.__class__, self.args),
|
||
|
|
self.__dict__.copy(),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _rebuild_provider_error(
|
||
|
|
cls: type, args: Tuple
|
||
|
|
) -> "ProviderError":
|
||
|
|
"""
|
||
|
|
Reconstruction helper for ProviderError.__reduce__.
|
||
|
|
|
||
|
|
Deliberately does not call cls(...). Creates a bare instance and sets
|
||
|
|
args; pickle then restores __dict__ as state, so every attribute the
|
||
|
|
original had comes back regardless of the subclass constructor.
|
||
|
|
"""
|
||
|
|
exc = cls.__new__(cls)
|
||
|
|
exc.args = tuple(args)
|
||
|
|
return exc
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Auth / session
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class AuthError(ProviderError):
|
||
|
|
"""401-style failure: token expired, invalid, or missing."""
|
||
|
|
|
||
|
|
|
||
|
|
class CredentialsError(AuthError):
|
||
|
|
"""Invalid username/password (as opposed to a stale token)."""
|
||
|
|
|
||
|
|
|
||
|
|
class SessionExpiredError(AuthError):
|
||
|
|
"""Server-side session is gone; a full re-login is required."""
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Access control
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class GeoBlockError(ProviderError):
|
||
|
|
"""Content is not available in the caller's region."""
|
||
|
|
|
||
|
|
|
||
|
|
class EntitlementError(ProviderError):
|
||
|
|
"""Authenticated, but not entitled to this content."""
|
||
|
|
|
||
|
|
|
||
|
|
class AccountRestrictedError(EntitlementError):
|
||
|
|
"""Account-level gate (e.g. VOD disabled for the whole account)."""
|
||
|
|
|
||
|
|
|
||
|
|
class PlaybackRestrictedError(ProviderError):
|
||
|
|
"""Playback is refused for a reason not covered above."""
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Content lookup
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class NotFoundError(ProviderError):
|
||
|
|
"""Content is known to the provider but has been removed.
|
||
|
|
|
||
|
|
At the manager top level, "this manager doesn't handle that content_id"
|
||
|
|
is signalled by returning None/[] -- not by raising NotFoundError. See
|
||
|
|
the "None vs exception" section in providers/_template/README.md.
|
||
|
|
|
||
|
|
NotFoundError is for the case where the provider *knows* the content
|
||
|
|
belongs in its domain but has been removed (e.g. a VOD detail fetch
|
||
|
|
returns 404 for an id that was recently listed). The orchestrator's
|
||
|
|
router will try other managers, then re-raise this if nobody resolves.
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
class BadRequestError(ProviderError):
|
||
|
|
"""400 -- the request was malformed for the endpoint called.
|
||
|
|
|
||
|
|
Used as a routing signal by providers that guess endpoint shape from an
|
||
|
|
opaque content_id (e.g. Magenta's page-vs-component dispatch). The
|
||
|
|
orchestrator's router does NOT swallow this -- providers that need that
|
||
|
|
behavior override handles_content_id() instead.
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Transport / server
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class RateLimitError(ProviderError):
|
||
|
|
"""429 -- caller should back off and retry."""
|
||
|
|
|
||
|
|
|
||
|
|
class ServerError(ProviderError):
|
||
|
|
"""5xx -- retryable."""
|
||
|
|
|
||
|
|
|
||
|
|
class TransportError(ProviderError):
|
||
|
|
"""Connection-level failure (DNS, TLS, timeout) -- no HTTP status."""
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Flow control
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class CatchupRequiredError(ProviderError):
|
||
|
|
"""This 'VOD' item is actually a catch-up entry from a linear channel."""
|
||
|
|
|
||
|
|
|
||
|
|
class NotImplementedYetError(ProviderError):
|
||
|
|
"""Feature captured but not yet wired up."""
|
||
|
|
|
||
|
|
|
||
|
|
class ConfigurationError(ProviderError):
|
||
|
|
"""Provider is misconfigured."""
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# HTTP status -> error class heuristic
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def default_error_for_status_heuristic(
|
||
|
|
status: int,
|
||
|
|
message: str,
|
||
|
|
*,
|
||
|
|
url: Optional[str] = None,
|
||
|
|
body_snippet: str = "",
|
||
|
|
) -> ProviderError:
|
||
|
|
"""
|
||
|
|
Heuristic mapping from HTTP status to ProviderError subclass.
|
||
|
|
|
||
|
|
This is a heuristic, not a contract. Providers with their own error
|
||
|
|
classification should construct the specific error directly rather than
|
||
|
|
relying on this. The 403 branch in particular uses a substring match on
|
||
|
|
body_snippet and is intentionally shallow so providers do not depend on
|
||
|
|
it accidentally.
|
||
|
|
"""
|
||
|
|
if status == 400:
|
||
|
|
return BadRequestError(message, status=status, url=url)
|
||
|
|
if status == 401:
|
||
|
|
return AuthError(message, status=status, url=url)
|
||
|
|
if status == 403:
|
||
|
|
lower = body_snippet.lower()
|
||
|
|
if "geo" in lower or "region" in lower:
|
||
|
|
return GeoBlockError(message, status=status, url=url)
|
||
|
|
return EntitlementError(message, status=status, url=url)
|
||
|
|
if status == 404:
|
||
|
|
return NotFoundError(message, status=status, url=url)
|
||
|
|
if status == 429:
|
||
|
|
return RateLimitError(message, status=status, url=url)
|
||
|
|
if 500 <= status < 600:
|
||
|
|
return ServerError(message, status=status, url=url)
|
||
|
|
return ProviderError(message, status=status, url=url)
|