round2 of new abstract managers

This commit is contained in:
Nirvana
2026-10-08 14:01:42 +02:00
parent 9b59eb4cbc
commit 7ecba40aa9
14 changed files with 623 additions and 300 deletions
@@ -1,18 +1,18 @@
# streaming_providers/base/managed_provider.py
"""
ManagedProvider -- StreamingProvider plus manager composition. DRAFT.
ManagedProvider -- StreamingProvider plus manager composition. DRAFT (round 2).
Supersedes provider_v2.py. It SUBCLASSES StreamingProvider, so registry
metadata, isinstance checks in the backend and the legacy mixins keep
working. Only new/migrated providers use it; legacy providers are
untouched.
It SUBCLASSES StreamingProvider, so registry metadata, isinstance checks in
the backend and the legacy mixins keep working. Only new/migrated providers
use it; legacy providers are untouched.
It moves out of every provider (see simplitv/provider.py) what is
identical in all of them:
It moves out of every provider what is identical in all of them:
* the _build_*() hooks (default None = capability absent)
* the implements_* flags
* _route() and the default get_manifest() / get_drm() routing
* get_channels(), EPG + header delegation, DRM validation
* delegation of the legacy mixin surface (VOD, recordings, favorites,
bookmarks, catchup) to the manager ABCs -- round 2
A provider still owns: http/auth/cache setup, the _build_*() bodies, and
anything with provider-specific grammar (simplitv: the catchup: branch
@@ -38,6 +38,25 @@ Routing rules
5. The manager that resolved an id is remembered (bounded, locked), so
headers go to the same manager that produced the manifest.
6. content_type narrows only for "live" and "vod"; anything else widens.
(The backend never passes content_type -- drm_operations calls
get_drm(content_id=..., **kw) -- so in practice rules 1-5 decide.)
Legacy surface -> manager (round 2)
-----------------------------------
Backend call (via *Operations) Manager method
----------------------------------------- ------------------------------------
get_vod_category / search_vod VodManager (VodPage -> paged dict)
get_recordings(include_deleted=) RecordingsManager.get_recordings
delete_recording(recording_id) RecordingsManager.delete_recording
get_favorites / add_favorite / remove_... FavoritesManager
get_bookmarks / update_bookmark / delete_.. BookmarksManager
catchup_window / get_catchup_window_for_.. CatchupManager.catchup_window_hours /
catchup_window_for_channel
get_catchup_manifest(+_headers) CatchupManager
get_catchup_drm CatchupManager; [] -> NotImplementedError
(see CATCHUP_DRM_FROM_LIVE)
get_segment_headers(id, start_time=...) CatchupManager.get_catchup_segment_headers
Not delegated (no manager ABC): timers, events, subscriptions.
"""
from __future__ import annotations
@@ -58,11 +77,15 @@ from .managers import (
VodManager,
)
from .models import StreamingChannel
from .models.bookmark import Bookmark, ContentType
from .models.favorite import Favorite, FavoriteType
from .models.recording import Recording
# Module paths below assume models/drm/{exceptions,drm_config}.py (see TODO R-1).
from .models.drm.drm_config import validate_drm_set
from .models.drm.exceptions import DRMError
from .protocols import DrmManagerProtocol
from .provider import StreamingProvider
from .vod import normalize_vod_result
_ROUTED = ("channels", "vod")
@@ -76,6 +99,12 @@ class ManagedProvider(StreamingProvider):
# auth.build_headers(), NOT the legacy {}). Set False to keep legacy {}.
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
# False: an empty CatchupManager.get_catchup_drm() result raises
# NotImplementedError, which the backend maps to "extract PSSH from the
# catchup manifest" (the legacy mixin contract). True: reuse the live DRM
# -- only for providers whose catchup is encrypted exactly like live.
CATCHUP_DRM_FROM_LIVE: ClassVar[bool] = False
ROUTE_CACHE_SIZE: ClassVar[int] = 512
# NOTE: `self.channels` holds the ChannelManager here (template
@@ -163,6 +192,9 @@ class ManagedProvider(StreamingProvider):
@property
def implements_epg(self) -> bool:
# Manager present AND it reports a window (epg_window != (0, 0)).
# NOTE: EPGOperations uses this flag to choose between the native
# path and the generic XMLTV path, so a manager that forgets to set
# epg_window silently switches the provider to XMLTV.
return self.epg is not None and self.epg.implements_epg
@property
@@ -258,6 +290,9 @@ class ManagedProvider(StreamingProvider):
# The legacy ProviderEpgMixin never looks at self.epg: epg_window is
# (0, 0), get_epg() returns [] and get_epg_grid() returns {} unless the
# provider overrides them. Delegate once, here.
# EPGOperations passes limit= and country= to get_epg; `limit` travels
# in **kw to the manager, `country` is dropped (the manager has its own).
# It hands over timezone-aware UTC datetimes.
@property
def epg_window(self) -> Tuple[int, int]:
@@ -307,6 +342,8 @@ class ManagedProvider(StreamingProvider):
channels = self.get_channels()
return super().to_output_format(channels)
# --- Manifest / headers -------------------------------------------
def get_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
return self._route(content_id, [
("channels", lambda m: m.get_channel_manifest(content_id, **kw)),
@@ -325,11 +362,27 @@ class ManagedProvider(StreamingProvider):
def get_segment_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
if self.HEADERS_FROM_MANAGERS:
# The backend's catchup DRM/segment pipeline calls
# get_segment_headers(id, start_time=..., end_time=..., epg_id=...)
# (drm_operations) and swallows exceptions; live calls carry no
# start_time. start_time therefore marks a catchup request.
if self.catchup is not None and "start_time" in kw:
rest = dict(kw)
start = rest.pop("start_time")
return self.catchup.get_catchup_segment_headers(
content_id,
start,
end_time=rest.pop("end_time", None),
epg_id=rest.pop("epg_id", None),
**rest,
)
name, mgr = self._routed_manager(content_id)
if name in _ROUTED: # both ChannelManager and VodManager have the hook
return mgr.get_segment_headers(content_id, **kw)
return self.get_manifest_headers(content_id, **kw)
# --- DRM -----------------------------------------------------------
def get_drm(
self,
content_id: str,
@@ -337,6 +390,9 @@ class ManagedProvider(StreamingProvider):
content_type: Optional[str] = None, # "live" | "vod" narrow; else widen
**kw: Any,
) -> List:
# drm_operations calls get_drm(content_id=..., **kw) with keywords
# only (drm_variant, preferred_quality, preferred_format, proxy
# extras) and never passes content_type.
if drm_variant is not None:
kw["drm_variant"] = drm_variant
@@ -373,4 +429,215 @@ class ManagedProvider(StreamingProvider):
f"{type(self).__name__}: invalid DRM configuration for "
f"{content_id}: {exc}"
) from exc
return configs
return configs
# --- Catchup -------------------------------------------------------
# Legacy ProviderCatchupMixin contract (see its docstrings):
# * catchup_window (hours) drives supports_catchup and
# validate_catchup_request; it must be the provider-wide MAXIMUM.
# * get_catchup_window_for_channel() is the per-channel hook.
# * get_catchup_manifest never falls back to the live manifest.
# * get_catchup_drm raises NotImplementedError => the pipeline extracts
# PSSH from the catchup manifest.
# Parameter names (content_id, start_time, end_time, epg_id) must stay
# identical: the legacy get_catchup_manifest_with_headers() calls these
# methods by keyword.
@property
def catchup_window(self) -> int:
return self.catchup.catchup_window_hours if self.catchup is not None else 0
def get_catchup_window_for_channel(self, content_id: str) -> int:
if self.catchup is None:
return 0
return self.catchup.catchup_window_for_channel(content_id)
def get_catchup_manifest(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw: Any,
) -> Optional[str]:
if self.catchup is None:
raise NotImplementedError(
f"{type(self).__name__} has no catchup manager"
)
return self.catchup.get_catchup_manifest(
content_id, start_time, end_time=end_time, epg_id=epg_id, **kw
)
def get_catchup_manifest_headers(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw: Any,
) -> Dict[str, str]:
if not self.HEADERS_FROM_MANAGERS or self.catchup is None:
return super().get_catchup_manifest_headers(
content_id, start_time, end_time, epg_id, **kw
)
return self.catchup.get_catchup_manifest_headers(
content_id, start_time, end_time=end_time, epg_id=epg_id, **kw
)
def get_catchup_drm(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
drm_variant: Optional[str] = None,
**kw: Any,
) -> List:
if self.catchup is None:
raise NotImplementedError(
f"{type(self).__name__} has no catchup manager"
)
if drm_variant is not None:
kw["drm_variant"] = drm_variant
configs = self.catchup.get_catchup_drm(
content_id, start_time, end_time=end_time, epg_id=epg_id, **kw
)
if configs:
return self._validate_drm(content_id, configs)
if self.CATCHUP_DRM_FROM_LIVE:
return self.get_drm(content_id, content_type="live", **kw)
raise NotImplementedError(
f"{type(self).__name__}: no catchup-specific DRM; the DRM "
f"pipeline extracts PSSH from the catchup manifest"
)
# --- VOD -----------------------------------------------------------
# VodOperations reads {"entries", "next_cursor", "total"} (or a list).
@staticmethod
def _vod_result(result: Any) -> Dict[str, Any]:
"""
Manager result -> the dict VodOperations reads.
normalize_vod_result (base/vod.py) is the single bridge for VodPage,
legacy dict, bare list and None. Entries stay model objects (as
legacy providers returned them); VodPage.to_dict() would serialise
them, which is the route layer's job.
"""
page = normalize_vod_result(result)
return {
"entries": list(page.entries),
"next_cursor": page.next_cursor,
"total": page.total,
}
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kw: Any,
):
if self.vod is None:
return super().get_vod_category(
content_id, cursor=cursor, page_size=page_size, **kw
)
return self._vod_result(
self.vod.get_vod_category(
content_id, cursor=cursor, page_size=page_size, **kw
)
)
def search_vod(
self,
query: str,
cursor: Optional[str] = None,
page_size: int = 24,
**kw: Any,
):
if self.vod is None:
return super().search_vod(
query, cursor=cursor, page_size=page_size, **kw
)
return self._vod_result(
self.vod.search_vod(query, cursor=cursor, page_size=page_size, **kw)
)
# --- Recordings ----------------------------------------------------
def get_recordings(
self, include_deleted: bool = False, **kw: Any
) -> List[Recording]:
if self.recordings is None:
return super().get_recordings(include_deleted=include_deleted, **kw)
return self.recordings.get_recordings(include_deleted=include_deleted, **kw)
def delete_recording(self, recording_id: str, **kw: Any) -> None:
if self.recordings is None:
return super().delete_recording(recording_id, **kw)
return self.recordings.delete_recording(recording_id, **kw)
# --- Favorites -----------------------------------------------------
def get_favorites(self, **kw: Any) -> List[Favorite]:
if self.favorites is None:
return super().get_favorites(**kw)
return self.favorites.get_favorites(**kw)
def add_favorite(
self,
content_id: str,
favorite_type: FavoriteType,
title: Optional[str] = None,
**kw: Any,
) -> Favorite:
if self.favorites is None:
return super().add_favorite(content_id, favorite_type, title, **kw)
return self.favorites.add_favorite(
content_id, favorite_type=favorite_type, title=title, **kw
)
def remove_favorite(self, content_id: str, **kw: Any) -> None:
if self.favorites is None:
return super().remove_favorite(content_id, **kw)
return self.favorites.remove_favorite(content_id, **kw)
# --- Bookmarks -----------------------------------------------------
# batch_update_bookmarks() stays the legacy implementation: it calls
# self.update_bookmark() per item, i.e. this delegation.
# Argument order follows the legacy mixin (position_seconds before
# content_type); ProviderManager's facade uses the opposite order, so
# callers must use keywords (verified: bookmark_operations calls by keyword).
def get_bookmarks(self, **kw: Any) -> List[Bookmark]:
if self.bookmarks is None:
return super().get_bookmarks(**kw)
return self.bookmarks.get_bookmarks(**kw)
def update_bookmark(
self,
content_id: str,
position_seconds: int,
content_type: ContentType,
duration_seconds: Optional[int] = None,
title: Optional[str] = None,
**kw: Any,
) -> Bookmark:
if self.bookmarks is None:
return super().update_bookmark(
content_id, position_seconds, content_type,
duration_seconds, title, **kw,
)
return self.bookmarks.update_bookmark(
content_id,
position_seconds=position_seconds,
content_type=content_type,
duration_seconds=duration_seconds,
title=title,
**kw,
)
def delete_bookmark(self, content_id: str, **kw: Any) -> None:
if self.bookmarks is None:
return super().delete_bookmark(content_id, **kw)
return self.bookmarks.delete_bookmark(content_id, **kw)
@@ -49,4 +49,4 @@ class ManagerBase(ABC):
self.http_manager = http_manager
self.auth = auth
self.country = country
self.config = config
self.config = config
@@ -5,6 +5,7 @@ CatchupManager ABC.
Public interface
----------------
catchup_window_hours -> int [concrete]
catchup_window_for_channel(content_id) -> int [concrete]
supports_catchup -> bool [concrete]
get_catchup_manifest(content_id, start_time, end_time=None, ...)
-> Optional[str] [abstract]
@@ -27,9 +28,13 @@ catchup manifest for the given content and window. It does NOT raise
NotFoundError -- "no catchup for this content" is a valid result, and
callers fall back to the live manifest.
get_catchup_drm returns [] when catchup shares DRM with live (the
common case), or a provider-specific list when catchup uses different
DRM.
get_catchup_drm returns [] when the provider has no catchup-specific DRM
configuration, or a provider-specific list when catchup uses its own DRM.
[] does NOT mean "same as live": ManagedProvider turns [] into
NotImplementedError, which the DRM pipeline
(drm_operations.get_catchup_content_drm_configs) reads as "extract the PSSH
from the catchup manifest itself". Providers whose catchup is encrypted
exactly like live set ManagedProvider.CATCHUP_DRM_FROM_LIVE = True.
What a catchup manifest is (and is not)
----------------------------------------
@@ -78,12 +83,22 @@ class CatchupManager(ManagerBase):
@property
def catchup_window_hours(self) -> int:
"""
Return the catchup window in hours.
Provider-wide catchup window in hours (the MAXIMUM over all
channels). Default 0 means no catchup. Providers override.
Default 0 means no catchup. Providers override.
This feeds the legacy provider.catchup_window, which the backend
uses to gate catchup and to validate request age without knowing
the channel. Per-channel windows go in catchup_window_for_channel().
"""
return 0
def catchup_window_for_channel(self, content_id: str) -> int:
"""
Catchup window in hours for one channel. Default: the provider-wide
window. Override when windows differ per channel (simpliTV: 2/3/4 h).
"""
return self.catchup_window_hours
@property
def supports_catchup(self) -> bool:
"""True when catchup_window_hours > 0."""
@@ -176,8 +191,11 @@ class CatchupManager(ManagerBase):
"""
DRM for catchup content.
Default: []. Most providers' catchup shares DRM with live (the
caller falls back to the channel manager's DRM), or has no DRM.
Default: [] = no catchup-specific DRM configuration. ManagedProvider
raises NotImplementedError for [], so the DRM pipeline extracts the
PSSH from the catchup manifest (also the right outcome for clear
streams). It does NOT fall back to live DRM unless the provider sets
CATCHUP_DRM_FROM_LIVE = True.
Providers whose catchup uses a distinct DRM configuration
(different license URL, different PSSH) override this.
@@ -13,10 +13,10 @@ Public interface
Constructor contract
--------------------
Four required keyword-only collaborators (see ManagerBase). Subclasses that
need extra state declare additional keyword-only args and store them on
self AFTER calling super().__init__. The base does NOT accept **kwargs -- a
typo at a call site becomes an immediate TypeError, which is what you want.
Four required keyword-only collaborators. Subclasses that need extra state
declare additional keyword-only args and store them on self AFTER calling
super().__init__. The base does NOT accept **kwargs -- a typo at a call
site becomes an immediate TypeError, which is what you want.
Return-value conventions
------------------------
@@ -48,8 +48,7 @@ class ChannelManager(ManagerBase):
Default: True. Override in providers whose channel content_ids have
a distinguishable grammar (e.g. numeric-only for live channels).
The orchestrator uses this to route get_manifest / get_drm without
a wasted request to the wrong manager. It is a cheap, I/O-free
PRE-FILTER: False means the manager is never asked.
a wasted request to the wrong manager.
"""
return True
@@ -35,7 +35,7 @@ Provider guidance
Favorites can be content of any type: a channel, a programme, a clip,
a series. The content_id is whatever the provider uses to identify the
favorited item. FavoriteType on the returned Favorite distinguishes
them (PROGRAM, CHANNEL, CLIP, LIVE, EVENT).
them (PROGRAM, CLIP, LIVE, EVENT).
"""
from __future__ import annotations
@@ -5,9 +5,9 @@ RecordingsManager ABC.
Public interface
----------------
handles_recording_id(recording_id) -> bool [concrete]
get_recordings(**kw) -> List[Channel] [abstract]
get_recordings(**kw) -> List[Recording] [abstract]
delete_recording(recording_id, **kw) -> None [abstract]
schedule_recording(content_id, **kw) -> Channel|bool [concrete, optional]
schedule_recording(content_id, **kw) -> Recording|bool [concrete, optional]
Constructor contract
--------------------
@@ -43,7 +43,7 @@ from abc import abstractmethod
from typing import Any, List
from ..errors import UnsupportedOperationError
from ..models import Channel
from ..models.recording import Recording
from ._base import ManagerBase
@@ -97,7 +97,7 @@ class RecordingsManager(ManagerBase):
# ------------------------------------------------------------------
@abstractmethod
def get_recordings(self, **kw: Any) -> List[Channel]:
def get_recordings(self, **kw: Any) -> List[Recording]:
"""
Return recordings for the authenticated user.
@@ -105,16 +105,17 @@ class RecordingsManager(ManagerBase):
NotFoundError for "no recordings" -- that is a valid empty
result, not a missing resource.
Recording objects are returned as Channel instances (or a
Channel subclass carrying extra fields such as recording_id,
start/stop times, and the underlying content_id). The base
Channel shape is preserved because downstream callers expect a
content_id and a name.
Items MUST be models.recording.Recording (or a subclass).
RecordingOperations filters on ``Recording.is_deleted`` and the
legacy mixin is typed List[Recording]; a bare Channel has no
``is_deleted`` and breaks that filter.
Providers whose recordings are conceptually distinct from their
channels (e.g. cloud-PVR recordings that store their own
programme metadata) should subclass Channel with the extra
fields they need, and document them in the subclass's docstring.
``include_deleted`` arrives in **kw (RecordingOperations always
passes it). Honour it when the backend can list deleted items;
otherwise ignore it -- the caller filters again.
Providers whose recordings carry extra fields subclass Recording and
document them in the subclass's docstring.
"""
raise NotImplementedError
@@ -150,7 +151,7 @@ class RecordingsManager(ManagerBase):
Schedule a recording of content_id.
Optional. Return value is provider-specific: some providers
return the created recording (a Channel), others return a bool
return the created recording (a Recording), others return a bool
indicating success. The ABC does not constrain the shape
because there is no shared one across providers.
+4 -11
View File
@@ -25,9 +25,9 @@ documents it here. The base class does not parse content_id. Examples:
Constructor contract
--------------------
Four required keyword-only collaborators (see ManagerBase). No **kwargs.
Subclasses accept extra keyword-only args explicitly and call
super().__init__ with only the four required.
Four required keyword-only collaborators. No **kwargs. Subclasses accept
extra keyword-only args explicitly and call super().__init__ with only
the four required. A typo at a call site becomes an immediate TypeError.
Return-value conventions
------------------------
@@ -59,7 +59,6 @@ class VodManager(ManagerBase):
Default: True. Override in providers whose VOD content_ids have a
distinguishable grammar (e.g. start with "details_" or "clip_").
Cheap, I/O-free PRE-FILTER: False means the manager is never asked.
"""
return True
@@ -108,13 +107,7 @@ class VodManager(ManagerBase):
def get_segment_headers(
self, content_id: str, **kw: Any
) -> Dict[str, str]:
"""
Headers for segment requests. Default: manifest headers.
Mirrors ChannelManager.get_segment_headers so the orchestrator can
ask any routed manager for segment headers. Override for providers
with token-bound segment URLs.
"""
"""Headers for segment requests. Default: VOD manifest headers."""
return self.get_vod_manifest_headers(content_id, **kw)
def get_vod_drm(
@@ -77,6 +77,26 @@ class ProviderMetadataMixin:
"""
return cls.SUPPORTED_COUNTRIES.copy()
@classmethod
def get_plugin_key(cls) -> str:
"""
The key this class is registered under in AVAILABLE_PROVIDERS (the
provider package's directory name, e.g. "simpli").
Falls back to the class-name derivation ("SimpliTVProvider" ->
"simplitv") only when the class is not registered (e.g. unit tests).
The derivation used to be the only source and disagreed with the
registry key for renamed providers.
"""
try:
from ... import AVAILABLE_PROVIDERS # lazy: package init imports us
except ImportError:
AVAILABLE_PROVIDERS = {}
for key, klass in AVAILABLE_PROVIDERS.items():
if klass is cls:
return key
return cls.__name__.lower().replace("provider", "")
@classmethod
def get_all_possible_instances(cls) -> List[Dict[str, Any]]:
"""
@@ -91,7 +111,7 @@ class ProviderMetadataMixin:
for country in cls.SUPPORTED_COUNTRIES:
instances.append(
{
"plugin": cls.__name__.lower().replace("provider", ""),
"plugin": cls.get_plugin_key(),
"country": country.upper(),
"label": cls.get_static_label(country),
"requires_country_suffix": True,
@@ -101,7 +121,7 @@ class ProviderMetadataMixin:
# Single-country provider
instances.append(
{
"plugin": cls.__name__.lower().replace("provider", ""),
"plugin": cls.get_plugin_key(),
"country": "DE", # Default country for single-country providers
"label": cls.get_static_label(),
"requires_country_suffix": False,
@@ -1,11 +0,0 @@
# streaming_providers/providers/simpli/__init__.py
"""
simpliTV streaming provider.
Registered via directory-based discovery. The module-level import here
is what makes the provider visible to the host.
"""
from .provider import SimpliTVProvider
__all__ = ["SimpliTVProvider"]
@@ -29,10 +29,12 @@ that value from the channel manager's AcquireContent response. If the
field is missing, the smallest observed window (2h) is used, so a seek
can never land outside the real window.
The ABC exposes catchup_window_hours as a single integer, which cannot
express a per-channel value. It returns the same smallest observed
window as the conservative answer: a host that trusts it will only
offer replays the shortest channel allows.
The ABC exposes catchup_window_hours as the provider-wide MAXIMUM (the
backend's validate_catchup_request uses it without knowing the channel),
and catchup_window_for_channel() as the per-channel value. The per-channel
limit is enforced here: get_restart_manifest() returns None for a start
outside the channel's own window, so a 4h global value never lets a 2h
channel seek outside its DVR window.
Contract
--------
@@ -42,7 +44,7 @@ swallowed). end_time is ignored: the API has no end bound.
"""
import time
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from ...base.errors import BadRequestError, NotFoundError
from ...base.managers import CatchupManager
@@ -56,9 +58,12 @@ from .channel_manager import (
from .constants import SimpliTVDefaults
# Smallest timeshift window observed across channels (7200s = 2h). Used
# as catchup_window_hours and as the per-channel fallback.
# Timeshift windows observed across channels: 7200 s (2h), 10800 s (3h),
# 14400 s (4h). The minimum is the per-channel fallback; the maximum is the
# provider-wide catchup_window_hours. Set _MAX_TIMESHIFT_HOURS back to
# _MIN_TIMESHIFT_HOURS to restore the previous conservative global value.
_MIN_TIMESHIFT_HOURS = 2
_MAX_TIMESHIFT_HOURS = 4
class SimpliTVCatchupManager(CatchupManager):
@@ -88,14 +93,27 @@ class SimpliTVCatchupManager(CatchupManager):
@property
def catchup_window_hours(self) -> int:
"""
Conservative global window, in hours.
The API advertises per-channel windows (2h, 3h, 4h); the ABC
only exposes a single integer, so the smallest observed value
is returned. get_restart_manifest uses the real per-channel
value.
Provider-wide window in hours: the largest observed per-channel
window (4h). Per-channel limits are enforced in
get_restart_manifest(); see catchup_window_for_channel().
"""
return _MIN_TIMESHIFT_HOURS
return _MAX_TIMESHIFT_HOURS
def catchup_window_for_channel(self, content_id: str) -> int:
"""
This channel's DVR window in hours (AcquireContent
AdditionalInfo.Epg_TimeshiftSeconds, floored), or the 2h minimum
when it cannot be determined. Costs at most one cached
AcquireContent call (PLAYBACK_CACHE_TTL).
"""
if self._channels is None:
return _MIN_TIMESHIFT_HOURS
try:
codename, _ = _channel_and_ts(content_id)
acquire = self._channels.acquire_content(codename)
except (BadRequestError, NotFoundError):
return _MIN_TIMESHIFT_HOURS
return max(1, _timeshift_seconds(acquire) // 3600)
# ------------------------------------------------------------------
# Abstract method
@@ -182,6 +200,32 @@ class SimpliTVCatchupManager(CatchupManager):
# Concrete overrides
# ------------------------------------------------------------------
def get_catchup_manifest_headers(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw,
) -> Dict[str, str]:
"""
Player headers: User-Agent + Origin, as for live (see
SimpliTVConfig.get_stream_headers()). The ABC default would send
the API headers (JSON Content-Type, tenant, Referer) to the CDN.
"""
return self.config.get_stream_headers()
def get_catchup_segment_headers(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw,
) -> Dict[str, str]:
"""Segment headers: same as the manifest headers (CDN, no token)."""
return self.config.get_stream_headers()
def get_catchup_drm(
self,
content_id: str,
@@ -119,6 +119,17 @@ class SimpliTVEpgManager(EpgManager):
"""
return self._fetch_available_window()
@property
def implements_epg(self) -> bool:
"""
Always True. The ABC derives this from epg_window != (0, 0), but
epg_window here is a (cached) GetAvailableDays request, and the
flag is read on every get_epg call and in the registry listing.
The window is informational and never (0, 0) (it falls back to
the EPG_PAST_DAYS / EPG_FUTURE_DAYS constants).
"""
return True
# ------------------------------------------------------------------
# get_epg / get_epg_grid
# ------------------------------------------------------------------
@@ -32,6 +32,7 @@ from typing import Any, Dict
from ...base.auth.base_auth import BaseAuthToken
from ...base.models import Channel
from ...base.models.recording import Recording
@dataclass
@@ -101,4 +102,46 @@ class SimpliTVChannel(Channel):
result["IsCatchupEnabled"] = self.is_catchup_enabled
result["RecordingId"] = self.recording_id
result["RecordingStatus"] = self.recording_status
return result
return result
@dataclass
class SimpliTVRecording(Recording):
"""
One NPvR recording.
Inherits Recording (not Channel): RecordingOperations filters on
`is_deleted`, and the legacy mixin is typed List[Recording].
Ids (two namespaces):
content_id "rec:<programme codename>" -- plays the recording
(get_manifest / get_drm) AND is what clients hold, since
Recording.recording_id is an alias of content_id.
remote_id the provider's recordingId -- what DeleteRecording takes.
SimpliTVRecordingsManager.delete_recording accepts either.
remote_status is the raw API status ("Recorded", "Scheduled",
"Failed"); `status` is the mapped RecordingStatus. Only "Recorded" is
playable. to_dict() keeps the keys the former SimpliTVChannel-based
recordings emitted (Codename, RecordingId, RecordingStatus,
CurrentStart, CurrentStop).
"""
codename: str = ""
remote_id: str = ""
remote_status: str = ""
current_start: str = ""
current_stop: str = ""
@property
def is_playable(self) -> bool:
return self.remote_status == "Recorded"
def to_dict(self) -> Dict[str, Any]:
result = super().to_dict()
result["Codename"] = self.codename
result["CurrentProgramme"] = self.name
result["CurrentStart"] = self.current_start
result["CurrentStop"] = self.current_stop
result["RecordingId"] = self.remote_id
result["RecordingStatus"] = self.remote_status
return result
@@ -2,11 +2,13 @@
"""
simpliTV orchestrator.
Owns shared resources (http_manager, caches, auth, managers) and
exposes the public StreamingProvider interface.
Owns shared resources (http_manager, caches, auth) and builds the
managers; everything that is identical across manager-based providers
(capability flags, content-id routing, manifest/DRM/header/EPG delegation,
the legacy recordings/catchup surface) lives in ManagedProvider.
Subclasses the existing StreamingProvider. Does NOT subclass any new
base class. Authentication is lazy -- no network I/O in __init__.
Subclasses ManagedProvider (itself a StreamingProvider). Authentication
is lazy -- no network I/O in __init__.
Manager wiring
--------------
@@ -19,20 +21,19 @@ Manager wiring
favorites -> None
bookmarks -> None
catchup -> SimpliTVCatchupManager (catchup: ids; borrows channels)
drm -> None (folded into channels)
drm -> None (DRM_IN_MANAGERS = True)
The catchup manager is built after the channel manager because it takes
the channel manager as a collaborator. No other manager has
cross-dependencies.
the channel manager as a collaborator (_init_managers builds in
dependency order). No other manager has cross-dependencies.
Routing
-------
_route is used only by get_manifest and get_drm -- the methods whose
input is an opaque content_id with several possible owners. The channel
manager owns live:, rec: and prog:; the catchup manager owns catchup:.
For catchup: the router parses the @<ts> suffix and hands the manager a
plain (content_id, start_time, end_time=None) triple. Malformed ids
raise BadRequestError and are not swallowed.
What stays here
---------------
Only grammar that belongs to simpliTV: the catchup:<channel>@<ts> id.
get_manifest and get_drm hand catchup: ids to the catchup manager (the
@<ts> suffix is parsed here into start_time); every other id goes to
ManagedProvider's router. Malformed ids raise BadRequestError and are not
swallowed.
Restart-from-beginning cannot be expressed as a manifest URL (it needs a
player-side seek), so it has its own method: get_restart().
@@ -41,19 +42,24 @@ Catchup windows
---------------
The DVR window is per-channel (AdditionalInfo.Epg_TimeshiftSeconds in
the AcquireContent response: 2h, 3h or 4h in the browser capture).
SimpliTVCatchupManager reads the per-channel value inside
get_restart_manifest. Its catchup_window_hours property returns the
conservative minimum (2h) because the ABC can only express a single
integer.
SimpliTVCatchupManager.catchup_window_hours is the provider-wide maximum
(what the backend's validate_catchup_request sees), and
catchup_window_for_channel() / get_restart_manifest() apply the real
per-channel value.
Headers
-------
HEADERS_FROM_MANAGERS = True: manifest and segment requests carry the
managers' headers (User-Agent + Origin, SimpliTVConfig.get_stream_headers).
The previous provider returned {} because it never overrode
get_manifest_headers, which made those manager hooks dead code. Set the
flag to False to restore the old {}.
"""
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
from typing import ClassVar, Dict, List, Optional, Tuple
from ...base.errors import NotFoundError
from ...base.managers import ChannelManager, VodManager
from ...base.managed_provider import ManagedProvider
from ...base.models.proxy_models import ProxyConfig
from ...base.protocols import DrmManagerProtocol
from ...base.provider import StreamingProvider
from .auth import SimpliTVAuth
from .catchup_manager import SimpliTVCatchupManager
@@ -62,8 +68,13 @@ from .constants import SimpliTVConfig, SimpliTVDefaults
from .epg_manager import SimpliTVEpgManager
from .recordings_manager import SimpliTVRecordingsManager
# Only the provider is public: provider discovery (streaming_providers/
# __init__.py) takes the first StreamingProvider subclass it finds in the
# package namespace, and ManagedProvider must never be that one.
__all__ = ["SimpliTVProvider"]
class SimpliTVProvider(StreamingProvider):
class SimpliTVProvider(ManagedProvider):
"""simpli streaming provider."""
PROVIDER_LABEL: ClassVar[str] = "simpli"
@@ -71,16 +82,16 @@ class SimpliTVProvider(StreamingProvider):
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["AT"]
# Manifest and DRM share one /Player/AcquireContent response, so DRM is
# served by SimpliTVChannelManager.get_channel_drm (no _build_drm()).
DRM_IN_MANAGERS: ClassVar[bool] = True
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
@property
def provider_name(self) -> str:
"""Return the provider name (matches the directory / registry key)."""
return SimpliTVDefaults.PROVIDER_NAME # "simpli"
# Only "live" and "vod" narrow the folded DRM search; anything else
# (None, "event", "catchup", a typo) tries both domains.
_LIVE_ONLY_CONTENT_TYPES = frozenset({"live"})
_VOD_ONLY_CONTENT_TYPES = frozenset({"vod"})
def __init__(
self,
country: str = "AT",
@@ -111,15 +122,8 @@ class SimpliTVProvider(StreamingProvider):
self._playback_cache: Dict = {}
self._recordings_cache: Dict = {}
# 4. Managers, in dependency order. Catchup needs channels.
self.channels = self._build_channels()
self.vod = self._build_vod()
self.epg = self._build_epg()
self.recordings = self._build_recordings()
self.favorites = self._build_favorites()
self.bookmarks = self._build_bookmarks()
self.catchup = self._build_catchup()
self.drm = self._build_drm()
# 4. Managers, in dependency order (catchup needs channels).
self._init_managers()
# ------------------------------------------------------------------
# Factory methods
@@ -144,11 +148,6 @@ class SimpliTVProvider(StreamingProvider):
playback_cache=self._playback_cache,
)
def _build_vod(self):
# No browseable VOD catalogue: content is live, catchup, or
# recordings.
return None
def _build_epg(self):
return SimpliTVEpgManager(
http_manager=self.http_manager,
@@ -166,12 +165,6 @@ class SimpliTVProvider(StreamingProvider):
recordings_cache=self._recordings_cache,
)
def _build_favorites(self):
return None # no favorites endpoint
def _build_bookmarks(self):
return None # no bookmarks / resume endpoint
def _build_catchup(self):
if self.channels is None:
return None
@@ -183,89 +176,11 @@ class SimpliTVProvider(StreamingProvider):
channels=self.channels,
)
def _build_drm(self) -> Optional[DrmManagerProtocol]:
# Folded into SimpliTVChannelManager -- the manifest and DRM
# share one /Player/AcquireContent response.
return None
# _build_vod / _build_favorites / _build_bookmarks / _build_drm:
# inherited (None): no catalogue, favorites, bookmarks; DRM is folded.
# ------------------------------------------------------------------
# Capability flags
# ------------------------------------------------------------------
@property
def implements_channels(self) -> bool:
return self.channels is not None
@property
def implements_vod(self) -> bool:
return self.vod is not None
@property
def implements_epg(self) -> bool:
return self.epg is not None
@property
def implements_recordings(self) -> bool:
return self.recordings is not None
@property
def implements_favorites(self) -> bool:
return self.favorites is not None
@property
def implements_bookmarks(self) -> bool:
return self.bookmarks is not None
@property
def implements_catchup(self) -> bool:
return self.catchup is not None
@property
def implements_drm(self) -> bool:
if self.drm is not None:
return True
folded_channels = (
self.channels is not None
and type(self.channels).get_channel_drm
is not ChannelManager.get_channel_drm
)
folded_vod = (
self.vod is not None
and type(self.vod).get_vod_drm
is not VodManager.get_vod_drm
)
return folded_channels or folded_vod
# ------------------------------------------------------------------
# Router
# ------------------------------------------------------------------
def _route(self, content_id: str, attempts: List[Tuple[Any, Callable]]):
"""
Try managers in order, skipping those whose handles_content_id()
rejects the id. Used only by get_manifest / get_drm.
NotFoundError is remembered and re-raised if nobody else
resolves the id ("existed but is gone" vs "nobody handles it").
BadRequestError is not caught: a malformed id surfaces.
"""
last_not_found: Optional[NotFoundError] = None
for manager, call in attempts:
if manager is None or not manager.handles_content_id(content_id):
continue
try:
result = call(manager)
except NotFoundError as e:
last_not_found = e
continue
if result:
return result
if last_not_found is not None:
raise last_not_found
return None
# ------------------------------------------------------------------
# Manifest routing
# catchup: ids (simpliTV-specific grammar)
# ------------------------------------------------------------------
def get_manifest(self, content_id: str, **kw) -> Optional[str]:
@@ -288,13 +203,34 @@ class SimpliTVProvider(StreamingProvider):
return self.catchup.get_catchup_manifest(
content_id, start_ts, None, **kw
)
return super().get_manifest(content_id, **kw)
return self._route(content_id, [
(self.channels, lambda m: m.get_channel_manifest(
content_id, **kw
)),
(self.vod, lambda m: m.get_vod_manifest(content_id, **kw)),
])
def get_drm(
self,
content_id: str,
drm_variant: Optional[str] = None,
content_type: Optional[str] = None,
**kw,
) -> List:
"""
DRM for the given content.
catchup: ids go to the catchup manager, which forwards to the
channel manager's DRM (the programme's own when replaying, the
channel's live DRM when restarting). Everything else is
ManagedProvider's folded-DRM routing.
"""
if content_id.startswith(SimpliTVDefaults.CATCHUP_PREFIX):
if self.catchup is None:
return []
_, start_ts = parse_catchup_id(content_id) # raises if bad
if drm_variant is not None:
kw["drm_variant"] = drm_variant
configs = self.catchup.get_catchup_drm(
content_id, start_ts, None, **kw
)
return self._validate_drm(content_id, configs)
return super().get_drm(content_id, drm_variant, content_type, **kw)
def get_restart(
self, content_id: str, start_time: int = 0, **kw
@@ -306,59 +242,8 @@ class SimpliTVProvider(StreamingProvider):
channel's DVR window) or None when the programme began outside
that window. The window is per-channel (see "Catchup windows"
above). The caller must seek; the URL alone plays live.
start_time=0 means "use the @<ts> embedded in a catchup: id".
"""
if self.catchup is None:
return None
return self.catchup.get_restart_manifest(content_id, start_time)
# ------------------------------------------------------------------
# DRM routing
# ------------------------------------------------------------------
def get_drm(
self,
content_id: str,
content_type: Optional[str] = None,
**kw,
) -> List:
"""
DRM for the given content.
content_type is an optional narrowing hint ("live"/"vod" only;
anything else tries both). catchup: ids go to the catchup
manager, which forwards to the channel manager's DRM.
"""
if self.drm is not None:
return self.drm.get_drm_configs(
content_id, content_type=content_type, **kw
)
if content_id.startswith(SimpliTVDefaults.CATCHUP_PREFIX):
if self.catchup is None:
return []
_, start_ts = parse_catchup_id(content_id) # raises if bad
return self.catchup.get_catchup_drm(
content_id, start_ts, None, **kw
)
attempts: List[Tuple[Any, Callable]] = []
if content_type not in self._VOD_ONLY_CONTENT_TYPES:
attempts.append(
(self.channels, lambda m: m.get_channel_drm(
content_id, **kw
))
)
if content_type not in self._LIVE_ONLY_CONTENT_TYPES:
attempts.append(
(self.vod, lambda m: m.get_vod_drm(content_id, **kw))
)
return self._route(content_id, attempts) or []
# ------------------------------------------------------------------
# Channels
# ------------------------------------------------------------------
def get_channels(self, **kw):
if self.channels is None:
return []
return self.channels.get_channels(**kw)
@@ -15,16 +15,15 @@ Recording identity (kept strictly separate from content identity):
This manager does not implement get_manifest -- do not add a parallel
manifest path here.
get_recordings() returns SimpliTVChannel objects whose content_id is the
"rec:..." id and whose recording_id carries the provider's id. Only
recordings whose recording_status is "Recorded" are playable;
"Scheduled" and "Failed" are listed but must not be played.
get_recordings() returns SimpliTVRecording objects (a Recording
subclass, so RecordingOperations can read `is_deleted`) whose content_id
is the "rec:..." id and whose remote_id carries the provider's id. Only
recordings whose remote_status is "Recorded" are playable; "Scheduled"
and "Failed" are listed but must not be played.
Note on construction: the base Content dataclass declares `content_id`
and a required `provider` field. `Channel.channel_id` is a property
that proxies to `content_id`, but dataclass __init__ bypasses
properties, so SimpliTVChannel is built with `content_id=` and
`provider=`.
Clients only hold the content_id (Recording.recording_id aliases it), so
delete_recording accepts the "rec:<codename>" id as well as the provider's
recordingId and resolves the former through GetRecordings.
Scheduling needs a *programme* codename (the EPG tile's own codename),
not a channel codename: pass prog:<programme codename>.
@@ -32,15 +31,15 @@ not a channel codename: pass prog:<programme codename>.
from typing import Dict, List, Optional
from ...base.errors import BadRequestError
from ...base.errors import BadRequestError, ItemNotFoundError
from ...base.managers import RecordingsManager
from ...base.models import Channel
from ...base.models.recording import Recording, RecordingStatus
from ...base.utils.logger import logger
from .channel_manager import parse_programme_id
from .constants import SimpliTVDefaults
from .helpers import transport_errors
from .models import SimpliTVChannel
from .helpers import parse_iso, transport_errors
from .models import SimpliTVRecording
class SimpliTVRecordingsManager(RecordingsManager):
@@ -69,9 +68,18 @@ class SimpliTVRecordingsManager(RecordingsManager):
# Abstract methods
# ------------------------------------------------------------------
def get_recordings(self, **kw) -> List[Channel]:
def get_recordings(self, **kw) -> List[Recording]:
"""
Return all NPvR recordings across every page ([] if none).
Return all NPvR recordings ([] if none).
`include_deleted` (passed by RecordingOperations in **kw) is
ignored: the API has no deleted state.
"""
return list(self._fetch_recordings())
def _fetch_recordings(self) -> List[SimpliTVRecording]:
"""
All NPvR recordings across every page.
The page index base is unverified (the addon's own paging is
broken and limit=99999 normally returns everything in one page),
@@ -79,7 +87,7 @@ class SimpliTVRecordingsManager(RecordingsManager):
de-duplicated by recording id: a page that adds nothing new ends
the walk instead of looping or returning duplicates.
"""
found: Dict[str, SimpliTVChannel] = {}
found: Dict[str, SimpliTVRecording] = {}
page = 0
while True:
# NOTE: GetRecordings names the token parameter `tokenValue`;
@@ -108,12 +116,12 @@ class SimpliTVRecordingsManager(RecordingsManager):
added = 0
for rec in data.get("recordings", []):
channel = self._rec_to_channel(rec)
key = channel.recording_id or (
f"{channel.codename}:{channel.current_start}"
recording = self._rec_to_recording(rec)
key = recording.remote_id or (
f"{recording.codename}:{recording.current_start}"
)
if key not in found:
found[key] = channel
found[key] = recording
added += 1
page += 1
@@ -129,20 +137,29 @@ class SimpliTVRecordingsManager(RecordingsManager):
def delete_recording(self, recording_id: str, **kw) -> None:
"""
Delete a recording by recording_id.
Delete a recording.
recording_id is either the provider's recordingId or the
"rec:<programme codename>" content id (what clients hold); the
latter is resolved through GetRecordings.
Raises:
KeyError: if the recording does not exist / was not deleted.
ItemNotFoundError: (also a KeyError) if the recording does not
exist / was not deleted.
ProviderError subclasses: on transport / backend failure
(typed errors such as AuthError pass through).
"""
if not recording_id:
raise KeyError("simpliTV: empty recording_id")
raise ItemNotFoundError("simpliTV: empty recording_id")
remote_id = recording_id
if recording_id.startswith(SimpliTVDefaults.RECORDING_PREFIX):
remote_id = self._remote_id_for(recording_id)
# NOTE: token is in the body (auth_body), not a header.
body = self.auth.auth_body({
"platformCodename": self.config.platform_codename,
"recordingId": recording_id,
"recordingId": remote_id,
})
with transport_errors("delete_recording"):
resp = self.http_manager.post(
@@ -155,15 +172,31 @@ class SimpliTVRecordingsManager(RecordingsManager):
# The API answers 200 with success:false for "not found";
# silently returning would hide real backend errors.
if not (data.get("result") or {}).get("success", False):
raise KeyError(
raise ItemNotFoundError(
f"simpliTV: recording {recording_id!r} not deleted "
f"(response: {data!r})"
)
def _remote_id_for(self, content_id: str) -> str:
"""Provider recordingId for a rec:<codename> content id."""
with transport_errors("resolve recording id"):
recordings = self._fetch_recordings()
for recording in recordings:
if recording.content_id == content_id and recording.remote_id:
return recording.remote_id
raise ItemNotFoundError(
f"simpliTV: no recording with id {content_id!r}"
)
# ------------------------------------------------------------------
# Optional override -- scheduling
# ------------------------------------------------------------------
@property
def supports_scheduling(self) -> bool:
"""schedule_recording() is implemented (prog:<codename> ids)."""
return True
def schedule_recording(self, content_id: str, **kw) -> bool:
"""
Schedule a recording of a programme.
@@ -196,24 +229,44 @@ class SimpliTVRecordingsManager(RecordingsManager):
# Internal
# ------------------------------------------------------------------
def _rec_to_channel(self, rec: dict) -> SimpliTVChannel:
def _rec_to_recording(self, rec: dict) -> SimpliTVRecording:
"""
Map one GetRecordings entry to SimpliTVChannel.
Map one GetRecordings entry to SimpliTVRecording.
content_id is "rec:<programme codename>" (the id used to play
the recording); recording_id is the provider's id (the id used
the recording); remote_id is the provider's id (the id used
to delete it). They are different namespaces.
"""
programme = rec.get("program") or {}
codename = programme.get("codename", "")
return SimpliTVChannel(
start_raw = programme.get("start", "")
stop_raw = programme.get("stop", "")
start, stop = parse_iso(start_raw), parse_iso(stop_raw)
duration = (
int((stop - start).total_seconds())
if start and stop and stop > start
else None
)
raw_status = rec.get("status", "")
return SimpliTVRecording(
name=programme.get("title", codename),
content_id=f"{SimpliTVDefaults.RECORDING_PREFIX}{codename}",
provider=SimpliTVDefaults.PROVIDER_NAME,
status=_STATUS_MAP.get(raw_status, RecordingStatus.PENDING),
recording_time=start,
duration_seconds=duration,
codename=codename,
current_programme=programme.get("title", ""),
current_start=programme.get("start", ""),
current_stop=programme.get("stop", ""),
recording_id=rec.get("recordingId", ""),
recording_status=rec.get("status", ""),
)
remote_id=rec.get("recordingId", ""),
remote_status=raw_status,
current_start=start_raw,
current_stop=stop_raw,
)
# API status -> RecordingStatus. Unknown values stay PENDING (not playable,
# not claimed to have failed).
_STATUS_MAP = {
"Recorded": RecordingStatus.COMPLETED,
"Scheduled": RecordingStatus.PENDING,
"Failed": RecordingStatus.FAILED,
}