mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-01 21:42:15 +02:00
Add timer support
This commit is contained in:
@@ -13,6 +13,7 @@ from .epg_operations import EPGOperations
|
||||
from .event_operations import EventOperations
|
||||
from .vod_operations import VodOperations
|
||||
from .recording_operations import RecordingOperations
|
||||
from .timer_operations import TimerOperations
|
||||
from .models import StreamingChannel
|
||||
from .provider_registry import ProviderRegistry
|
||||
from .subscription_operations import SubscriptionOperations
|
||||
@@ -38,6 +39,7 @@ class ProviderManager:
|
||||
self.event_ops = EventOperations(self.registry)
|
||||
self.vod_ops = VodOperations(self.registry)
|
||||
self.recording_ops = RecordingOperations(self.registry)
|
||||
self.timer_ops = TimerOperations(self.registry)
|
||||
|
||||
# Backward compatibility - expose managers directly
|
||||
self.drm_plugin_manager = self.drm_ops.drm_plugin_manager
|
||||
@@ -303,6 +305,48 @@ class ProviderManager:
|
||||
provider_name, recording_id, **kwargs
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# TIMER OPERATIONS (delegate to TimerOperations)
|
||||
# ==========================================================================
|
||||
|
||||
def get_timer_types(self, provider_name: str) -> List:
|
||||
return self.timer_ops.get_timer_types(provider_name)
|
||||
|
||||
def get_all_timer_types(self) -> Dict[str, List]:
|
||||
return self.timer_ops.get_all_timer_types()
|
||||
|
||||
def get_timers(
|
||||
self,
|
||||
provider_name: str,
|
||||
include_inactive: bool = False,
|
||||
) -> List:
|
||||
return self.timer_ops.get_timers(
|
||||
provider_name, include_inactive=include_inactive
|
||||
)
|
||||
|
||||
def get_all_timers(self, include_inactive: bool = False) -> Dict[str, List]:
|
||||
return self.timer_ops.get_all_timers(include_inactive=include_inactive)
|
||||
|
||||
def get_timer(self, provider_name: str, client_index: int):
|
||||
return self.timer_ops.get_timer(provider_name, client_index)
|
||||
|
||||
def add_timer(self, provider_name: str, timer, **kwargs):
|
||||
return self.timer_ops.add_timer(provider_name, timer, **kwargs)
|
||||
|
||||
def update_timer(self, provider_name: str, timer, **kwargs):
|
||||
return self.timer_ops.update_timer(provider_name, timer, **kwargs)
|
||||
|
||||
def delete_timer(
|
||||
self,
|
||||
provider_name: str,
|
||||
client_index: int,
|
||||
force_delete: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
return self.timer_ops.delete_timer(
|
||||
provider_name, client_index, force_delete=force_delete, **kwargs
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# SUBSCRIPTION OPERATIONS (delegate to SubscriptionOperations)
|
||||
# ==========================================================================
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
# streaming_providers/base/models/timer.py
|
||||
"""
|
||||
Timer model.
|
||||
|
||||
A Timer represents a scheduled recording instruction — it tells the backend
|
||||
*when* and *what* to record. Unlike a Recording (which is captured content
|
||||
that can be played back), a Timer is purely a management object: it has no
|
||||
manifest and no DRM. It therefore does NOT inherit from Content.
|
||||
|
||||
Mapping to the PVR Timer API is noted in inline comments where names diverge.
|
||||
|
||||
Two companion enumerations are provided:
|
||||
- TimerState — lifecycle state of the timer itself
|
||||
- TimerWeekday — bitmask constants for recurring-timer day selection
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum, IntFlag
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enumerations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TimerState(Enum):
|
||||
"""
|
||||
Lifecycle state of the timer.
|
||||
Maps to PVR_TIMER_STATE_* constants.
|
||||
"""
|
||||
NEW = "NEW" # just created, not yet saved to provider
|
||||
SCHEDULED = "SCHEDULED" # saved, waiting for start time
|
||||
RECORDING = "RECORDING" # capture is currently in progress
|
||||
COMPLETED = "COMPLETED" # recording finished successfully
|
||||
ABORTED = "ABORTED" # recording was manually stopped
|
||||
CANCELLED = "CANCELLED" # timer was deleted before it fired
|
||||
CONFLICT = "CONFLICT" # overlaps with another timer / resource issue
|
||||
ERROR = "ERROR" # provider reported an error
|
||||
|
||||
|
||||
class TimerWeekday(IntFlag):
|
||||
"""
|
||||
Bitmask for recurring timer weekday selection.
|
||||
Maps to PVR_WEEKDAY_* constants.
|
||||
Combine with bitwise OR: TimerWeekday.MONDAY | TimerWeekday.WEDNESDAY
|
||||
"""
|
||||
NONE = 0
|
||||
MONDAY = 1
|
||||
TUESDAY = 2
|
||||
WEDNESDAY = 4
|
||||
THURSDAY = 8
|
||||
FRIDAY = 16
|
||||
SATURDAY = 32
|
||||
SUNDAY = 64
|
||||
ALL_DAYS = MONDAY | TUESDAY | WEDNESDAY | THURSDAY | FRIDAY | SATURDAY | SUNDAY
|
||||
WEEKDAYS = MONDAY | TUESDAY | WEDNESDAY | THURSDAY | FRIDAY
|
||||
WEEKEND = SATURDAY | SUNDAY
|
||||
|
||||
|
||||
class DuplicateHandling(Enum):
|
||||
"""
|
||||
Strategy for preventing duplicate episode recordings.
|
||||
Maps to PVR_TIMER_DUPLICATE_PREVENTION_* values.
|
||||
"""
|
||||
NONE = 0 # record all, even duplicates
|
||||
SAME_EPG_ID = 1 # skip if same EPG event ID already recorded
|
||||
SAME_TITLE = 2 # skip if title matches an existing recording
|
||||
SAME_SUBTITLE = 3 # skip if subtitle/episode name matches
|
||||
SAME_DESCRIPTION = 4 # skip if description matches
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timer dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Timer:
|
||||
"""
|
||||
A scheduled recording instruction.
|
||||
|
||||
Required fields (must be set for every timer):
|
||||
client_index — unique identifier assigned by the provider
|
||||
state — current lifecycle state
|
||||
timer_type_id — references a TimerType.type_id
|
||||
title — display name / series title
|
||||
|
||||
All other fields are optional and mirror PVR API capabilities.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Identity (required)
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetClientIndex / GetClientIndex (unsigned int)
|
||||
client_index: int = 0
|
||||
|
||||
# PVR: SetState / GetState
|
||||
state: TimerState = TimerState.NEW
|
||||
|
||||
# PVR: SetTimerType / GetTimerType (unsigned int — references TimerType.type_id)
|
||||
timer_type_id: int = 1
|
||||
|
||||
# PVR: SetTitle / GetTitle
|
||||
title: str = ""
|
||||
|
||||
# Provider this timer belongs to (not a PVR field; used for routing)
|
||||
provider: str = ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hierarchy — series / parent timers
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetParentClientIndex / GetParentClientIndex
|
||||
# 0 = top-level timer (no parent)
|
||||
parent_client_index: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Channel
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetClientChannelUid / GetClientChannelUid
|
||||
# -1 = "any channel" (used by EPG-search timers)
|
||||
client_channel_uid: int = -1
|
||||
|
||||
# Human-readable channel name for display (not in PVR table)
|
||||
channel_name: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timing
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetStartTime / GetStartTime (Unix timestamp; 0 = ASAP)
|
||||
start_time: Optional[datetime] = None
|
||||
|
||||
# PVR: SetEndTime / GetEndTime (Unix timestamp; 0 = open-ended)
|
||||
end_time: Optional[datetime] = None
|
||||
|
||||
# PVR: SetStartAnyTime / GetStartAnyTime
|
||||
# True = ignore start_time; begin whenever the event is detected
|
||||
start_any_time: bool = False
|
||||
|
||||
# PVR: SetEndAnyTime / GetEndAnyTime
|
||||
# True = ignore end_time; record until the event ends naturally
|
||||
end_any_time: bool = False
|
||||
|
||||
# PVR: SetFirstDay / GetFirstDay (Unix timestamp)
|
||||
# For recurring timers: do not fire before this date
|
||||
first_day: Optional[datetime] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pre/post padding
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetMarginStart / GetMarginStart (minutes)
|
||||
margin_start: int = 0
|
||||
|
||||
# PVR: SetMarginEnd / GetMarginEnd (minutes)
|
||||
margin_end: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# EPG search / linkage
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetEPGSearchString / GetEPGSearchString
|
||||
# Used by search-based recurring timers (e.g. "record anything matching 'Tatort'")
|
||||
epg_search_string: Optional[str] = None
|
||||
|
||||
# PVR: SetFullTextEpgSearch / GetFullTextEpgSearch
|
||||
# True = match anywhere in title/description; False = title-only
|
||||
full_text_epg_search: bool = False
|
||||
|
||||
# PVR: SetEPGUid / GetEPGUid (unsigned int)
|
||||
# EPG event ID this timer was created from (0 = not linked to an EPG event)
|
||||
epg_uid: int = 0
|
||||
|
||||
# Convenience: the full EPG event ID including provider prefix (not in PVR table)
|
||||
epg_event_id: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recurring schedule
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetWeekdays / GetWeekdays (bitmask of TimerWeekday)
|
||||
weekdays: TimerWeekday = TimerWeekday.NONE
|
||||
|
||||
# PVR: SetPreventDuplicateEpisodes / GetPreventDuplicateEpisodes
|
||||
prevent_duplicate_episodes: DuplicateHandling = DuplicateHandling.NONE
|
||||
|
||||
# Series link — used by some providers to group related timers
|
||||
# PVR: SetSeriesLink / GetSeriesLink
|
||||
series_link: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recording management
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetDirectory / GetDirectory
|
||||
directory: Optional[str] = None
|
||||
|
||||
# PVR: SetPriority / GetPriority
|
||||
# Higher value = higher priority when resources are scarce
|
||||
priority: int = 50
|
||||
|
||||
# PVR: SetLifetime / GetLifetime (days; 0 = keep forever)
|
||||
lifetime: int = 0
|
||||
|
||||
# PVR: SetMaxRecordings / GetMaxRecordings
|
||||
# 0 = unlimited; positive integer = keep only this many recordings
|
||||
max_recordings: int = 0
|
||||
|
||||
# PVR: SetRecordingGroup / GetRecordingGroup (unsigned int)
|
||||
recording_group: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Genre metadata
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetGenreType / GetGenreType (DVB genre code)
|
||||
genre_type: Optional[int] = None
|
||||
|
||||
# PVR: SetGenreSubType / GetGenreSubType
|
||||
genre_sub_type: Optional[int] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal / bookkeeping (not in PVR table)
|
||||
# ------------------------------------------------------------------
|
||||
# When this timer record was last fetched or updated locally
|
||||
last_updated: Optional[datetime] = None
|
||||
|
||||
# Free-form summary from the provider (maps to description in UI)
|
||||
description: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Post-init
|
||||
# ------------------------------------------------------------------
|
||||
def __post_init__(self):
|
||||
# Normalise: a recurring timer with explicit weekdays but NONE set
|
||||
# should fall back to the provider's default — we do not force it here
|
||||
# but we expose a helper property for convenience.
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_recurring(self) -> bool:
|
||||
"""True if this timer fires on multiple days (weekday mask is set)."""
|
||||
return self.weekdays != TimerWeekday.NONE
|
||||
|
||||
@property
|
||||
def is_epg_based(self) -> bool:
|
||||
"""True if this timer was created from or linked to an EPG event."""
|
||||
return bool(self.epg_uid) or bool(self.epg_event_id)
|
||||
|
||||
@property
|
||||
def is_search_based(self) -> bool:
|
||||
"""True if this timer uses an EPG keyword search."""
|
||||
return bool(self.epg_search_string)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""True if the timer is still expected to produce a recording."""
|
||||
return self.state in (TimerState.NEW, TimerState.SCHEDULED, TimerState.RECORDING)
|
||||
|
||||
@property
|
||||
def duration_minutes(self) -> Optional[int]:
|
||||
"""Planned recording duration in minutes (None if open-ended)."""
|
||||
if self.start_time and self.end_time:
|
||||
delta = self.end_time - self.start_time
|
||||
return int(delta.total_seconds() // 60)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
# Identity
|
||||
"ClientIndex": self.client_index,
|
||||
"State": self.state.value,
|
||||
"TimerTypeId": self.timer_type_id,
|
||||
"Title": self.title,
|
||||
"Provider": self.provider,
|
||||
# Hierarchy
|
||||
"ParentClientIndex": self.parent_client_index,
|
||||
# Channel
|
||||
"ClientChannelUid": self.client_channel_uid,
|
||||
"ChannelName": self.channel_name,
|
||||
# Timing
|
||||
"StartTime": self.start_time.isoformat() if self.start_time else None,
|
||||
"EndTime": self.end_time.isoformat() if self.end_time else None,
|
||||
"StartAnyTime": self.start_any_time,
|
||||
"EndAnyTime": self.end_any_time,
|
||||
"FirstDay": self.first_day.isoformat() if self.first_day else None,
|
||||
"DurationMinutes": self.duration_minutes,
|
||||
# Padding
|
||||
"MarginStart": self.margin_start,
|
||||
"MarginEnd": self.margin_end,
|
||||
# EPG
|
||||
"EpgSearchString": self.epg_search_string,
|
||||
"FullTextEpgSearch": self.full_text_epg_search,
|
||||
"EpgUid": self.epg_uid,
|
||||
"EpgEventId": self.epg_event_id,
|
||||
# Recurring
|
||||
"Weekdays": self.weekdays.value,
|
||||
"PreventDuplicateEpisodes": self.prevent_duplicate_episodes.value,
|
||||
"SeriesLink": self.series_link,
|
||||
# Management
|
||||
"Directory": self.directory,
|
||||
"Priority": self.priority,
|
||||
"Lifetime": self.lifetime,
|
||||
"MaxRecordings": self.max_recordings,
|
||||
"RecordingGroup": self.recording_group,
|
||||
# Genre
|
||||
"GenreType": self.genre_type,
|
||||
"GenreSubType": self.genre_sub_type,
|
||||
# Convenience flags
|
||||
"IsRecurring": self.is_recurring,
|
||||
"IsEpgBased": self.is_epg_based,
|
||||
"IsSearchBased": self.is_search_based,
|
||||
"IsActive": self.is_active,
|
||||
# Metadata
|
||||
"Description": self.description,
|
||||
"LastUpdated": self.last_updated.isoformat() if self.last_updated else None,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate(self) -> List[str]:
|
||||
warnings = []
|
||||
if not self.title:
|
||||
warnings.append("title must not be empty")
|
||||
if self.timer_type_id <= 0:
|
||||
warnings.append("timer_type_id must be a positive integer")
|
||||
if self.start_time and self.end_time and self.start_time >= self.end_time:
|
||||
warnings.append("start_time must be before end_time")
|
||||
if self.margin_start < 0:
|
||||
warnings.append("margin_start must not be negative")
|
||||
if self.margin_end < 0:
|
||||
warnings.append("margin_end must not be negative")
|
||||
if self.priority < 0 or self.priority > 100:
|
||||
warnings.append("priority should be between 0 and 100")
|
||||
if self.lifetime < 0:
|
||||
warnings.append("lifetime must be 0 (keep forever) or a positive number of days")
|
||||
if self.max_recordings < 0:
|
||||
warnings.append("max_recordings must not be negative")
|
||||
if self.is_recurring and self.epg_uid:
|
||||
warnings.append(
|
||||
"timer has both weekdays and epg_uid set; "
|
||||
"recurring EPG-based timers should use series_link instead"
|
||||
)
|
||||
return warnings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def create_one_shot(
|
||||
cls,
|
||||
title: str,
|
||||
provider: str,
|
||||
client_channel_uid: int,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
timer_type_id: int = 1,
|
||||
**kwargs,
|
||||
) -> "Timer":
|
||||
"""Create a single-event timer for a specific channel and time window."""
|
||||
return cls(
|
||||
title=title,
|
||||
provider=provider,
|
||||
client_channel_uid=client_channel_uid,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
timer_type_id=timer_type_id,
|
||||
state=TimerState.SCHEDULED,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_recurring(
|
||||
cls,
|
||||
title: str,
|
||||
provider: str,
|
||||
client_channel_uid: int,
|
||||
weekdays: TimerWeekday,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
timer_type_id: int = 1,
|
||||
prevent_duplicate_episodes: DuplicateHandling = DuplicateHandling.SAME_EPG_ID,
|
||||
**kwargs,
|
||||
) -> "Timer":
|
||||
"""Create a recurring weekly timer (e.g. record every Monday at 20:15)."""
|
||||
return cls(
|
||||
title=title,
|
||||
provider=provider,
|
||||
client_channel_uid=client_channel_uid,
|
||||
weekdays=weekdays,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
timer_type_id=timer_type_id,
|
||||
prevent_duplicate_episodes=prevent_duplicate_episodes,
|
||||
state=TimerState.SCHEDULED,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_epg_based(
|
||||
cls,
|
||||
title: str,
|
||||
provider: str,
|
||||
client_channel_uid: int,
|
||||
epg_uid: int,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
timer_type_id: int = 1,
|
||||
**kwargs,
|
||||
) -> "Timer":
|
||||
"""Create a timer tied to a specific EPG event."""
|
||||
return cls(
|
||||
title=title,
|
||||
provider=provider,
|
||||
client_channel_uid=client_channel_uid,
|
||||
epg_uid=epg_uid,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
timer_type_id=timer_type_id,
|
||||
state=TimerState.SCHEDULED,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_search_based(
|
||||
cls,
|
||||
title: str,
|
||||
provider: str,
|
||||
epg_search_string: str,
|
||||
timer_type_id: int = 1,
|
||||
client_channel_uid: int = -1,
|
||||
full_text_epg_search: bool = False,
|
||||
prevent_duplicate_episodes: DuplicateHandling = DuplicateHandling.SAME_SUBTITLE,
|
||||
**kwargs,
|
||||
) -> "Timer":
|
||||
"""Create a keyword-search timer that records any matching EPG event."""
|
||||
return cls(
|
||||
title=title,
|
||||
provider=provider,
|
||||
client_channel_uid=client_channel_uid,
|
||||
epg_search_string=epg_search_string,
|
||||
full_text_epg_search=full_text_epg_search,
|
||||
prevent_duplicate_episodes=prevent_duplicate_episodes,
|
||||
timer_type_id=timer_type_id,
|
||||
state=TimerState.SCHEDULED,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,338 @@
|
||||
# streaming_providers/base/models/timer_type.py
|
||||
"""
|
||||
Timer type model.
|
||||
|
||||
A TimerType describes a *category* of timer a provider supports — not a
|
||||
single scheduled recording, but the template that governs what options are
|
||||
available when creating one. Providers expose their supported timer types via
|
||||
get_timer_types(); clients use this information to build a creation UI and to
|
||||
know which timer_type_id to pass when calling add_timer().
|
||||
|
||||
Mapping to PVRTimerType is noted in inline comments.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attribute bitmask constants
|
||||
# (mirrors PVR_TIMER_TYPE_ATTRIBUTE_* from the Kodi PVR API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TimerTypeAttribute:
|
||||
"""
|
||||
Bitmask constants describing the capabilities of a TimerType.
|
||||
Combine with bitwise OR to build the `attributes` field.
|
||||
"""
|
||||
NONE = 0x00000000
|
||||
IS_MANUAL = 0x00000001 # timer is set manually (not EPG-based)
|
||||
IS_REPEATING = 0x00000002 # fires on multiple days / events
|
||||
IS_EPG_BASED = 0x00000004 # linked to a specific EPG event
|
||||
SUPPORTS_ENABLE_DISABLE = 0x00000008 # timer can be enabled/disabled without deleting
|
||||
SUPPORTS_CHANNELS = 0x00000010 # channel selection is meaningful
|
||||
SUPPORTS_START_TIME = 0x00000020 # start time can be specified
|
||||
SUPPORTS_START_ANY_TIME = 0x00000040 # "start any time" flag is supported
|
||||
SUPPORTS_END_TIME = 0x00000080 # end time can be specified
|
||||
SUPPORTS_END_ANY_TIME = 0x00000100 # "end any time" flag is supported
|
||||
SUPPORTS_FIRST_DAY = 0x00000200 # first-day constraint is supported
|
||||
SUPPORTS_WEEKDAYS = 0x00000400 # weekday mask is supported
|
||||
SUPPORTS_EPG_SEARCH = 0x00000800 # keyword EPG search is supported
|
||||
SUPPORTS_FULL_TEXT_SEARCH = 0x00001000 # full-text EPG search is supported
|
||||
SUPPORTS_RECORDING_FOLDERS = 0x00002000 # directory selection is supported
|
||||
SUPPORTS_PRIORITIES = 0x00004000 # priority selection is supported
|
||||
SUPPORTS_LIFETIMES = 0x00008000 # lifetime selection is supported
|
||||
SUPPORTS_PADDING = 0x00010000 # pre/post margin padding is supported
|
||||
SUPPORTS_RECORDING_GROUP = 0x00020000 # recording group assignment is supported
|
||||
SUPPORTS_MAX_RECORDINGS = 0x00040000 # max-recordings limit is supported
|
||||
SUPPORTS_DUPLICATE_CHECK = 0x00080000 # duplicate-episode prevention is supported
|
||||
REQUIRES_EPG_TAG_ON_CREATE = 0x00100000 # an EPG tag must be provided at creation time
|
||||
FORBIDS_EPG_TAG_ON_CREATE = 0x00200000 # EPG tag must NOT be provided at creation time
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: labelled integer option (used for dropdown selections)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class TimerTypeIntOption:
|
||||
"""
|
||||
A single labelled option in a numeric selection list.
|
||||
Mirrors PVRTypeIntValue used for priority, lifetime, etc.
|
||||
"""
|
||||
value: int
|
||||
description: str
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {"Value": self.value, "Description": self.description}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TimerType dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class TimerType:
|
||||
"""
|
||||
Describes a category of timer supported by a provider.
|
||||
|
||||
Required fields:
|
||||
type_id — unique identifier within the provider (PVR: SetId / GetId)
|
||||
attributes — bitmask of TimerTypeAttribute constants
|
||||
|
||||
Optional fields control which UI elements appear when the type is selected
|
||||
and what default values are pre-filled.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Identity (required)
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetId / GetId (unsigned int; must be > 0)
|
||||
type_id: int = 1
|
||||
|
||||
# PVR: SetAttributes / GetAttributes (bitmask of TimerTypeAttribute)
|
||||
attributes: int = TimerTypeAttribute.NONE
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Display
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetDescription / GetDescription
|
||||
description: str = ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Priority selection
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetPriorities / GetPriorities
|
||||
# List of (value, label) options presented in the priority dropdown.
|
||||
# Empty list = free-text integer input.
|
||||
priority_options: List[TimerTypeIntOption] = field(default_factory=list)
|
||||
|
||||
# PVR: SetPrioritiesDefault / GetPrioritiesDefault
|
||||
priority_default: int = 50
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifetime selection
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetLifetimes / GetLifetimes
|
||||
lifetime_options: List[TimerTypeIntOption] = field(default_factory=list)
|
||||
|
||||
# PVR: SetLifetimesDefault / GetLifetimesDefault (days; 0 = keep forever)
|
||||
lifetime_default: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Duplicate-episode prevention selection
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetPreventDuplicateEpisodes / GetPreventDuplicateEpisodes
|
||||
prevent_duplicate_options: List[TimerTypeIntOption] = field(default_factory=list)
|
||||
|
||||
# PVR: SetPreventDuplicateEpisodesDefault / GetPreventDuplicateEpisodesDefault
|
||||
prevent_duplicate_default: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recording group selection
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetRecordingGroups / GetRecordingGroups
|
||||
recording_group_options: List[TimerTypeIntOption] = field(default_factory=list)
|
||||
|
||||
# PVR: SetRecordingGroupDefault / GetRecordingGroupDefault
|
||||
recording_group_default: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Max recordings selection
|
||||
# ------------------------------------------------------------------
|
||||
# PVR: SetMaxRecordings / GetMaxRecordings
|
||||
max_recordings_options: List[TimerTypeIntOption] = field(default_factory=list)
|
||||
|
||||
# PVR: SetMaxRecordingsDefault / GetMaxRecordingsDefault (0 = unlimited)
|
||||
max_recordings_default: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convenience attribute helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def supports(self, attribute: int) -> bool:
|
||||
"""Check whether a specific attribute flag is set."""
|
||||
return bool(self.attributes & attribute)
|
||||
|
||||
@property
|
||||
def is_manual(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.IS_MANUAL)
|
||||
|
||||
@property
|
||||
def is_repeating(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.IS_REPEATING)
|
||||
|
||||
@property
|
||||
def is_epg_based(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.IS_EPG_BASED)
|
||||
|
||||
@property
|
||||
def supports_channels(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.SUPPORTS_CHANNELS)
|
||||
|
||||
@property
|
||||
def supports_weekdays(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.SUPPORTS_WEEKDAYS)
|
||||
|
||||
@property
|
||||
def supports_epg_search(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.SUPPORTS_EPG_SEARCH)
|
||||
|
||||
@property
|
||||
def supports_padding(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.SUPPORTS_PADDING)
|
||||
|
||||
@property
|
||||
def supports_priorities(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.SUPPORTS_PRIORITIES)
|
||||
|
||||
@property
|
||||
def supports_lifetimes(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.SUPPORTS_LIFETIMES)
|
||||
|
||||
@property
|
||||
def supports_duplicate_check(self) -> bool:
|
||||
return self.supports(TimerTypeAttribute.SUPPORTS_DUPLICATE_CHECK)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"TypeId": self.type_id,
|
||||
"Attributes": self.attributes,
|
||||
"Description": self.description,
|
||||
# Priority
|
||||
"PriorityOptions": [o.to_dict() for o in self.priority_options],
|
||||
"PriorityDefault": self.priority_default,
|
||||
# Lifetime
|
||||
"LifetimeOptions": [o.to_dict() for o in self.lifetime_options],
|
||||
"LifetimeDefault": self.lifetime_default,
|
||||
# Duplicate handling
|
||||
"PreventDuplicateOptions": [o.to_dict() for o in self.prevent_duplicate_options],
|
||||
"PreventDuplicateDefault": self.prevent_duplicate_default,
|
||||
# Recording group
|
||||
"RecordingGroupOptions": [o.to_dict() for o in self.recording_group_options],
|
||||
"RecordingGroupDefault": self.recording_group_default,
|
||||
# Max recordings
|
||||
"MaxRecordingsOptions": [o.to_dict() for o in self.max_recordings_options],
|
||||
"MaxRecordingsDefault": self.max_recordings_default,
|
||||
# Derived flags (convenience for clients that don't parse bitmasks)
|
||||
"IsManual": self.is_manual,
|
||||
"IsRepeating": self.is_repeating,
|
||||
"IsEpgBased": self.is_epg_based,
|
||||
"SupportsChannels": self.supports_channels,
|
||||
"SupportsWeekdays": self.supports_weekdays,
|
||||
"SupportsEpgSearch": self.supports_epg_search,
|
||||
"SupportsPadding": self.supports_padding,
|
||||
"SupportsPriorities": self.supports_priorities,
|
||||
"SupportsLifetimes": self.supports_lifetimes,
|
||||
"SupportsDuplicateCheck": self.supports_duplicate_check,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory helpers for the most common timer type patterns
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def make_manual_one_shot(
|
||||
cls,
|
||||
type_id: int = 1,
|
||||
description: str = "Manual one-time recording",
|
||||
**kwargs,
|
||||
) -> "TimerType":
|
||||
"""Record a specific channel between two explicit timestamps."""
|
||||
return cls(
|
||||
type_id=type_id,
|
||||
description=description,
|
||||
attributes=(
|
||||
TimerTypeAttribute.IS_MANUAL
|
||||
| TimerTypeAttribute.SUPPORTS_CHANNELS
|
||||
| TimerTypeAttribute.SUPPORTS_START_TIME
|
||||
| TimerTypeAttribute.SUPPORTS_END_TIME
|
||||
| TimerTypeAttribute.SUPPORTS_PRIORITIES
|
||||
| TimerTypeAttribute.SUPPORTS_LIFETIMES
|
||||
| TimerTypeAttribute.SUPPORTS_PADDING
|
||||
| TimerTypeAttribute.FORBIDS_EPG_TAG_ON_CREATE
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def make_epg_one_shot(
|
||||
cls,
|
||||
type_id: int = 2,
|
||||
description: str = "EPG-based one-time recording",
|
||||
**kwargs,
|
||||
) -> "TimerType":
|
||||
"""Record a single EPG event."""
|
||||
return cls(
|
||||
type_id=type_id,
|
||||
description=description,
|
||||
attributes=(
|
||||
TimerTypeAttribute.IS_EPG_BASED
|
||||
| TimerTypeAttribute.SUPPORTS_CHANNELS
|
||||
| TimerTypeAttribute.SUPPORTS_START_TIME
|
||||
| TimerTypeAttribute.SUPPORTS_END_TIME
|
||||
| TimerTypeAttribute.SUPPORTS_PRIORITIES
|
||||
| TimerTypeAttribute.SUPPORTS_LIFETIMES
|
||||
| TimerTypeAttribute.SUPPORTS_PADDING
|
||||
| TimerTypeAttribute.REQUIRES_EPG_TAG_ON_CREATE
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def make_manual_recurring(
|
||||
cls,
|
||||
type_id: int = 3,
|
||||
description: str = "Manual recurring recording",
|
||||
**kwargs,
|
||||
) -> "TimerType":
|
||||
"""Record the same time slot on selected weekdays."""
|
||||
return cls(
|
||||
type_id=type_id,
|
||||
description=description,
|
||||
attributes=(
|
||||
TimerTypeAttribute.IS_MANUAL
|
||||
| TimerTypeAttribute.IS_REPEATING
|
||||
| TimerTypeAttribute.SUPPORTS_CHANNELS
|
||||
| TimerTypeAttribute.SUPPORTS_START_TIME
|
||||
| TimerTypeAttribute.SUPPORTS_END_TIME
|
||||
| TimerTypeAttribute.SUPPORTS_WEEKDAYS
|
||||
| TimerTypeAttribute.SUPPORTS_FIRST_DAY
|
||||
| TimerTypeAttribute.SUPPORTS_PRIORITIES
|
||||
| TimerTypeAttribute.SUPPORTS_LIFETIMES
|
||||
| TimerTypeAttribute.SUPPORTS_PADDING
|
||||
| TimerTypeAttribute.SUPPORTS_MAX_RECORDINGS
|
||||
| TimerTypeAttribute.FORBIDS_EPG_TAG_ON_CREATE
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def make_epg_search(
|
||||
cls,
|
||||
type_id: int = 4,
|
||||
description: str = "EPG keyword search recording",
|
||||
**kwargs,
|
||||
) -> "TimerType":
|
||||
"""Record every EPG event whose title/description matches a keyword."""
|
||||
return cls(
|
||||
type_id=type_id,
|
||||
description=description,
|
||||
attributes=(
|
||||
TimerTypeAttribute.IS_EPG_BASED
|
||||
| TimerTypeAttribute.IS_REPEATING
|
||||
| TimerTypeAttribute.SUPPORTS_CHANNELS
|
||||
| TimerTypeAttribute.SUPPORTS_EPG_SEARCH
|
||||
| TimerTypeAttribute.SUPPORTS_FULL_TEXT_SEARCH
|
||||
| TimerTypeAttribute.SUPPORTS_PRIORITIES
|
||||
| TimerTypeAttribute.SUPPORTS_LIFETIMES
|
||||
| TimerTypeAttribute.SUPPORTS_DUPLICATE_CHECK
|
||||
| TimerTypeAttribute.SUPPORTS_MAX_RECORDINGS
|
||||
| TimerTypeAttribute.FORBIDS_EPG_TAG_ON_CREATE
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -19,6 +19,8 @@ from .models.proxy_models import ProxyConfig
|
||||
from .models import DRMConfig, Event, StreamingChannel
|
||||
from .models.subscription import SubscriptionPackage, UserSubscription
|
||||
from .models.recording import Recording
|
||||
from .models.timer import Timer
|
||||
from .models.timer_type import TimerType
|
||||
from .network import HTTPManager, HTTPManagerFactory
|
||||
from .utils.logger import logger
|
||||
|
||||
@@ -749,6 +751,118 @@ class StreamingProvider(ABC):
|
||||
"Override this method to support recording deletion."
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# TIMERS
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def implements_timers(self) -> bool:
|
||||
"""
|
||||
True if this provider supports scheduled recording timers.
|
||||
|
||||
Return False (default) for providers that only offer live or VOD content
|
||||
and have no PVR/timer backend. TimerOperations skips providers where
|
||||
this returns False when aggregating across all providers.
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_timer_types(self) -> List["TimerType"]:
|
||||
"""
|
||||
Return the timer types this provider supports.
|
||||
|
||||
Providers that support timers MUST override this method and return at
|
||||
least one TimerType so that clients know what fields to present when
|
||||
creating a timer.
|
||||
|
||||
Returns:
|
||||
List of TimerType objects, or [] if timers are not supported.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_timers(self, **kwargs) -> List["Timer"]:
|
||||
"""
|
||||
Return all timers (scheduled recordings) for the authenticated user.
|
||||
|
||||
Args:
|
||||
**kwargs: Provider-specific filtering options.
|
||||
|
||||
Returns:
|
||||
List of Timer objects, or [] if timers are not supported.
|
||||
"""
|
||||
return []
|
||||
|
||||
def add_timer(self, timer: "Timer", **kwargs) -> "Timer":
|
||||
"""
|
||||
Schedule a new timer on the provider's backend.
|
||||
|
||||
Args:
|
||||
timer: Timer to create. timer.client_index is ignored — the
|
||||
provider allocates and sets it on the returned object.
|
||||
**kwargs: Provider-specific arguments.
|
||||
|
||||
Returns:
|
||||
The saved Timer with client_index populated by the provider.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the provider rejects the timer (e.g. scheduling
|
||||
conflict, unsupported timer type, insufficient
|
||||
permissions).
|
||||
|
||||
Default raises NotImplementedError so misconfigured providers fail
|
||||
loudly rather than silently doing nothing.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} does not implement add_timer(). "
|
||||
"Override this method to support timer creation."
|
||||
)
|
||||
|
||||
def update_timer(self, timer: "Timer", **kwargs) -> "Timer":
|
||||
"""
|
||||
Update an existing timer on the provider's backend.
|
||||
|
||||
Args:
|
||||
timer: Timer with updated fields. timer.client_index identifies
|
||||
the record to modify.
|
||||
**kwargs: Provider-specific arguments.
|
||||
|
||||
Returns:
|
||||
The updated Timer as confirmed by the provider.
|
||||
|
||||
Raises:
|
||||
KeyError: If no timer with that client_index exists.
|
||||
RuntimeError: If the provider refuses the update (e.g. the timer
|
||||
is currently recording).
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} does not implement update_timer(). "
|
||||
"Override this method to support timer updates."
|
||||
)
|
||||
|
||||
def delete_timer(
|
||||
self, client_index: int, force_delete: bool = False, **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Delete a timer on the provider's backend.
|
||||
|
||||
Args:
|
||||
client_index: Timer identifier to delete.
|
||||
force_delete: If True and the timer is currently recording, abort
|
||||
the ongoing capture before deleting.
|
||||
**kwargs: Provider-specific arguments.
|
||||
|
||||
Returns:
|
||||
None on success.
|
||||
|
||||
Raises:
|
||||
KeyError: If no timer with that client_index exists.
|
||||
RuntimeError: If the provider refuses deletion (e.g. recording in
|
||||
progress and force_delete is False).
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} does not implement delete_timer(). "
|
||||
"Override this method to support timer deletion."
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# CATCHUP ABSTRACT METHODS
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# streaming_providers/base/timer_operations.py
|
||||
"""
|
||||
Timer-related operations separated from core registry.
|
||||
Mirrors the structure of RecordingOperations.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .models.timer import Timer
|
||||
from .models.timer_type import TimerType
|
||||
from .utils.logger import logger
|
||||
|
||||
|
||||
class TimerOperations:
|
||||
"""Handles all timer-related operations."""
|
||||
|
||||
def __init__(self, registry):
|
||||
self.registry = registry
|
||||
logger.debug("TimerOperations: Initialized")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timer types
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_timer_types(self, provider_name: str) -> List[TimerType]:
|
||||
"""
|
||||
Get the timer types supported by a specific provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider to query.
|
||||
|
||||
Returns:
|
||||
List of TimerType objects describing what kinds of timers
|
||||
the provider can accept.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider is not found or disabled.
|
||||
"""
|
||||
provider = self.registry.get_provider(provider_name)
|
||||
if not provider:
|
||||
raise ValueError(f"Provider '{provider_name}' not found or disabled")
|
||||
|
||||
timer_types = provider.get_timer_types()
|
||||
logger.debug(
|
||||
f"Retrieved {len(timer_types)} timer types from '{provider_name}'"
|
||||
)
|
||||
return timer_types
|
||||
|
||||
def get_all_timer_types(self) -> Dict[str, List[TimerType]]:
|
||||
"""
|
||||
Get timer types from all enabled providers.
|
||||
|
||||
Returns:
|
||||
Dict mapping provider name → list of TimerType objects.
|
||||
"""
|
||||
enabled = self.registry.get_enabled_providers()
|
||||
result = {}
|
||||
|
||||
for name in enabled:
|
||||
try:
|
||||
if self._provider_implements_timers(name):
|
||||
result[name] = self.get_timer_types(name)
|
||||
else:
|
||||
result[name] = []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get timer types from '{name}': {e}")
|
||||
result[name] = []
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Listing timers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_timers(
|
||||
self,
|
||||
provider_name: str,
|
||||
include_inactive: bool = False,
|
||||
) -> List[Timer]:
|
||||
"""
|
||||
Get timers from a specific provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider to query.
|
||||
include_inactive: If True, include completed, cancelled, and
|
||||
error-state timers in addition to active ones.
|
||||
|
||||
Returns:
|
||||
List of Timer objects.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider is not found or disabled.
|
||||
"""
|
||||
provider = self.registry.get_provider(provider_name)
|
||||
if not provider:
|
||||
raise ValueError(f"Provider '{provider_name}' not found or disabled")
|
||||
|
||||
timers = provider.get_timers()
|
||||
|
||||
if not include_inactive:
|
||||
timers = [t for t in timers if t.is_active]
|
||||
|
||||
logger.info(
|
||||
f"Retrieved {len(timers)} timers from '{provider_name}' "
|
||||
f"(include_inactive={include_inactive})"
|
||||
)
|
||||
return timers
|
||||
|
||||
def get_all_timers(
|
||||
self,
|
||||
include_inactive: bool = False,
|
||||
) -> Dict[str, List[Timer]]:
|
||||
"""
|
||||
Get timers from all enabled providers.
|
||||
|
||||
Args:
|
||||
include_inactive: If True, include non-active timers.
|
||||
|
||||
Returns:
|
||||
Dict mapping provider name → list of Timer objects.
|
||||
"""
|
||||
enabled = self.registry.get_enabled_providers()
|
||||
logger.info(f"Fetching timers from {len(enabled)} providers")
|
||||
|
||||
result = {}
|
||||
total = 0
|
||||
|
||||
for name in enabled:
|
||||
try:
|
||||
if self._provider_implements_timers(name):
|
||||
timers = self.get_timers(name, include_inactive=include_inactive)
|
||||
else:
|
||||
timers = []
|
||||
result[name] = timers
|
||||
total += len(timers)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get timers from '{name}': {e}")
|
||||
result[name] = []
|
||||
|
||||
logger.info(f"Retrieved {total} total timers")
|
||||
return result
|
||||
|
||||
def get_timer(
|
||||
self, provider_name: str, client_index: int
|
||||
) -> Optional[Timer]:
|
||||
"""
|
||||
Get a single timer by its client index.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider.
|
||||
client_index: Timer identifier (== Timer.client_index).
|
||||
|
||||
Returns:
|
||||
Timer object, or None if not found.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider is not found or disabled.
|
||||
"""
|
||||
# Fetch all (including inactive) so we can look up any timer by index
|
||||
timers = self.get_timers(provider_name, include_inactive=True)
|
||||
match = next((t for t in timers if t.client_index == client_index), None)
|
||||
if match:
|
||||
logger.debug(
|
||||
f"Found timer {client_index} from '{provider_name}'"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Timer {client_index} not found on '{provider_name}'"
|
||||
)
|
||||
return match
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Creating timers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_timer(
|
||||
self, provider_name: str, timer: Timer, **kwargs
|
||||
) -> Timer:
|
||||
"""
|
||||
Schedule a new timer on the provider.
|
||||
|
||||
The provider assigns client_index and returns the saved Timer (which
|
||||
may differ from the input if the provider normalises fields).
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider.
|
||||
timer: Timer object to create. client_index is ignored —
|
||||
the provider allocates one.
|
||||
**kwargs: Additional provider-specific arguments.
|
||||
|
||||
Returns:
|
||||
The saved Timer as confirmed by the provider.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider is not found or disabled.
|
||||
RuntimeError: If the provider rejects the timer (e.g. conflict,
|
||||
insufficient permissions, unsupported timer type).
|
||||
"""
|
||||
provider = self.registry.get_provider(provider_name)
|
||||
if not provider:
|
||||
raise ValueError(f"Provider '{provider_name}' not found or disabled")
|
||||
|
||||
saved_timer = provider.add_timer(timer, **kwargs)
|
||||
logger.info(
|
||||
f"Added timer '{saved_timer.title}' (index={saved_timer.client_index}) "
|
||||
f"on '{provider_name}'"
|
||||
)
|
||||
return saved_timer
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Updating timers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_timer(
|
||||
self, provider_name: str, timer: Timer, **kwargs
|
||||
) -> Timer:
|
||||
"""
|
||||
Update an existing timer on the provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider.
|
||||
timer: Timer object with updated fields.
|
||||
timer.client_index identifies the record to update.
|
||||
**kwargs: Additional provider-specific arguments.
|
||||
|
||||
Returns:
|
||||
The updated Timer as confirmed by the provider.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider is not found or disabled.
|
||||
KeyError: If no timer with that client_index exists.
|
||||
RuntimeError: If the provider refuses the update (e.g. the timer
|
||||
is currently recording and cannot be modified).
|
||||
"""
|
||||
provider = self.registry.get_provider(provider_name)
|
||||
if not provider:
|
||||
raise ValueError(f"Provider '{provider_name}' not found or disabled")
|
||||
|
||||
updated_timer = provider.update_timer(timer, **kwargs)
|
||||
logger.info(
|
||||
f"Updated timer {timer.client_index} ('{timer.title}') "
|
||||
f"on '{provider_name}'"
|
||||
)
|
||||
return updated_timer
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Deleting timers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def delete_timer(
|
||||
self,
|
||||
provider_name: str,
|
||||
client_index: int,
|
||||
force_delete: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a timer on the provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider.
|
||||
client_index: Timer identifier to delete.
|
||||
force_delete: If True and the timer is currently recording, abort
|
||||
the ongoing capture and delete. If False and the
|
||||
timer is recording, the provider should raise
|
||||
RuntimeError.
|
||||
**kwargs: Additional provider-specific arguments.
|
||||
|
||||
Returns:
|
||||
None on success.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider is not found or disabled.
|
||||
KeyError: If no timer with that client_index exists.
|
||||
RuntimeError: If the provider refuses deletion (e.g. timer is
|
||||
recording and force_delete is False).
|
||||
"""
|
||||
provider = self.registry.get_provider(provider_name)
|
||||
if not provider:
|
||||
raise ValueError(f"Provider '{provider_name}' not found or disabled")
|
||||
|
||||
provider.delete_timer(client_index, force_delete=force_delete, **kwargs)
|
||||
logger.info(
|
||||
f"Deleted timer {client_index} from '{provider_name}' "
|
||||
f"(force={force_delete})"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _provider_implements_timers(self, provider_name: str) -> bool:
|
||||
"""Return True if the named provider declares timer support."""
|
||||
provider = self.registry.get_provider(provider_name)
|
||||
if not provider:
|
||||
return False
|
||||
return getattr(provider, "implements_timers", False)
|
||||
@@ -696,13 +696,126 @@ class MoveTvVodManager:
|
||||
self, path_ids: List[str], **kwargs
|
||||
) -> List[Union[VodCategory, VodItem]]:
|
||||
"""
|
||||
Fetch children of a VOD category node (seasons, episodes, ...).
|
||||
Fetch children of a VOD category node.
|
||||
|
||||
Not yet implemented -- log the browser request when drilling into a
|
||||
series or category, then implement here.
|
||||
Path structure:
|
||||
[] or ["root"] → Return content types (Film, Serija)
|
||||
["root", "Film"] → Return categories for movies
|
||||
["root", "Serija"] → Return categories for series
|
||||
["root", "Film", "123"] → Return VOD items for that category
|
||||
["root", "Serija", "456"] → Return VOD items for that series category
|
||||
"""
|
||||
|
||||
# Handle root level - return content types
|
||||
if not path_ids or path_ids[0] == "root":
|
||||
# Fetch filters to get content types
|
||||
filters = self.get_vod_filters()
|
||||
|
||||
categories = []
|
||||
for content_type in filters.content_types:
|
||||
# Map content type ID to a display name
|
||||
type_name = content_type.name # "Film" or "Serija"
|
||||
|
||||
categories.append(
|
||||
VodCategory(
|
||||
name=type_name,
|
||||
content_id=f"content_type_{content_type.content_type_id}",
|
||||
provider=self._provider.provider_name,
|
||||
# Store the content_type_id in the path for next level
|
||||
# The UI will pass this as path_ids[1]
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"{self._provider.provider_name}: VOD root -> "
|
||||
f"{len(categories)} content types"
|
||||
)
|
||||
return categories
|
||||
|
||||
# Handle content type level (e.g., ["root", "Film"] or ["root", "Serija"])
|
||||
if len(path_ids) == 2 and path_ids[0] == "root":
|
||||
content_type_name = path_ids[1]
|
||||
|
||||
# Fetch filters to get categories for this content type
|
||||
filters = self.get_vod_filters()
|
||||
|
||||
# Find the content type ID
|
||||
content_type_id = None
|
||||
for ct in filters.content_types:
|
||||
if ct.name == content_type_name:
|
||||
content_type_id = ct.content_type_id
|
||||
break
|
||||
|
||||
if not content_type_id:
|
||||
logger.warning(
|
||||
f"{self._provider.provider_name}: Unknown content type '{content_type_name}'"
|
||||
)
|
||||
return []
|
||||
|
||||
# Return categories/genres for this content type
|
||||
categories = []
|
||||
for category in filters.categories:
|
||||
# Create a category that can be browsed further
|
||||
categories.append(
|
||||
VodCategory(
|
||||
name=category.name,
|
||||
content_id=str(category.content_id),
|
||||
provider=self._provider.provider_name,
|
||||
# The path will be ["root", content_type_name, category_id]
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"{self._provider.provider_name}: VOD content type '{content_type_name}' -> "
|
||||
f"{len(categories)} categories"
|
||||
)
|
||||
return categories
|
||||
|
||||
# Handle category level - return VOD items
|
||||
if len(path_ids) >= 3 and path_ids[0] == "root":
|
||||
content_type_name = path_ids[1]
|
||||
category_id = path_ids[2]
|
||||
|
||||
# Fetch filters to get content type ID
|
||||
filters = self.get_vod_filters()
|
||||
content_type_id = None
|
||||
for ct in filters.content_types:
|
||||
if ct.name == content_type_name:
|
||||
content_type_id = ct.content_type_id
|
||||
break
|
||||
|
||||
if not content_type_id:
|
||||
return []
|
||||
|
||||
# Map content type name to tag ID (optional filtering)
|
||||
tag_map = {
|
||||
"Film": 1, # FILM tag
|
||||
"Serija": 2, # SERIJA tag
|
||||
}
|
||||
tag_id = tag_map.get(content_type_name)
|
||||
|
||||
# Fetch VOD items for this category
|
||||
vod_page = self.get_vod_items(
|
||||
page=1,
|
||||
sort="newest",
|
||||
tag_id=tag_id,
|
||||
category_id=int(category_id) if category_id != "root" else None,
|
||||
content_type_id=content_type_id,
|
||||
)
|
||||
|
||||
# Convert VodItems to the expected return format
|
||||
# (VodOperations likely expects VodItem or similar)
|
||||
items = []
|
||||
for item in vod_page.items:
|
||||
items.append(item)
|
||||
|
||||
logger.info(
|
||||
f"{self._provider.provider_name}: VOD category '{category_id}' -> "
|
||||
f"{len(items)} items"
|
||||
)
|
||||
return items
|
||||
|
||||
logger.warning(
|
||||
f"{self._provider.provider_name}: get_vod_category({path_ids}) -- "
|
||||
"not yet implemented; log the browse/drill-down endpoint to add support."
|
||||
f"{self._provider.provider_name}: Unhandled VOD path: {path_ids}"
|
||||
)
|
||||
return []
|
||||
Reference in New Issue
Block a user