Add timer support

This commit is contained in:
Nirvana
2026-04-02 16:13:45 +02:00
parent 44c1a944a3
commit d5aaecd8e0
8 changed files with 1930 additions and 5 deletions
+44
View File
@@ -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,
)
+114
View File
@@ -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 []
+565
View File
@@ -0,0 +1,565 @@
#!/usr/bin/env python3
"""
Timer-related route handlers.
Mirrors the structure of recordings.py.
Endpoints
---------
GET /api/providers/<provider>/timer-types
GET /api/providers/<provider>/timers
GET /api/providers/<provider>/timers/<client_index>
POST /api/providers/<provider>/timers
PUT /api/providers/<provider>/timers/<client_index>
DELETE /api/providers/<provider>/timers/<client_index>
"""
from bottle import request, response
from streaming_providers.base.models.timer import (
DuplicateHandling,
Timer,
TimerState,
TimerWeekday,
)
from streaming_providers.base.utils import logger
from datetime import datetime
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_datetime(value: str, field_name: str) -> datetime:
"""
Parse an ISO 8601 datetime string, raising ValueError with a clear
message if parsing fails.
"""
try:
return datetime.fromisoformat(value)
except (ValueError, TypeError):
raise ValueError(
f"'{field_name}' must be an ISO 8601 datetime string "
f"(e.g. '2025-06-01T20:15:00'), got: {value!r}"
)
def _timer_from_body(body: dict, client_index: int = 0) -> Timer:
"""
Deserialise a request body dictionary into a Timer object.
Required keys: title, timer_type_id, provider
All other keys are optional and map directly to Timer fields.
Raises:
ValueError: If required keys are missing or a field cannot be parsed.
"""
for required in ("title", "timer_type_id"):
if required not in body:
raise ValueError(f"Missing required field: '{required}'")
kwargs = {}
# Timing
if "start_time" in body and body["start_time"] is not None:
kwargs["start_time"] = _parse_datetime(body["start_time"], "start_time")
if "end_time" in body and body["end_time"] is not None:
kwargs["end_time"] = _parse_datetime(body["end_time"], "end_time")
if "first_day" in body and body["first_day"] is not None:
kwargs["first_day"] = _parse_datetime(body["first_day"], "first_day")
# Enumerations
if "state" in body:
try:
kwargs["state"] = TimerState(body["state"])
except ValueError:
raise ValueError(
f"Invalid state '{body['state']}'. "
f"Valid values: {[s.value for s in TimerState]}"
)
if "weekdays" in body:
try:
kwargs["weekdays"] = TimerWeekday(int(body["weekdays"]))
except (ValueError, TypeError):
raise ValueError(
f"'weekdays' must be an integer bitmask "
f"(e.g. {TimerWeekday.MONDAY.value} for Monday). "
f"Got: {body['weekdays']!r}"
)
if "prevent_duplicate_episodes" in body:
try:
kwargs["prevent_duplicate_episodes"] = DuplicateHandling(
int(body["prevent_duplicate_episodes"])
)
except (ValueError, TypeError):
raise ValueError(
f"Invalid prevent_duplicate_episodes value: "
f"{body['prevent_duplicate_episodes']!r}. "
f"Valid values: {[d.value for d in DuplicateHandling]}"
)
# Scalar fields — passed through directly if present
scalar_fields = (
"timer_type_id",
"title",
"parent_client_index",
"client_channel_uid",
"channel_name",
"start_any_time",
"end_any_time",
"margin_start",
"margin_end",
"epg_search_string",
"full_text_epg_search",
"epg_uid",
"epg_event_id",
"series_link",
"directory",
"priority",
"lifetime",
"max_recordings",
"recording_group",
"genre_type",
"genre_sub_type",
"description",
)
for f in scalar_fields:
if f in body:
kwargs[f] = body[f]
return Timer(client_index=client_index, **kwargs)
# ---------------------------------------------------------------------------
# Route setup
# ---------------------------------------------------------------------------
def setup_timers_routes(app, manager, service):
"""Register timer-related routes on the Bottle app."""
# ------------------------------------------------------------------ #
# GET /api/providers/<provider>/timer-types #
# ------------------------------------------------------------------ #
@app.route("/api/providers/<provider>/timer-types", method="GET")
def get_provider_timer_types(provider):
"""
Get the timer types supported by a specific provider.
Returns:
{
"provider": "provider_name",
"timer_types": [ { ...TimerType fields... } ],
"count": 2
}
"""
try:
try:
timer_types = manager.timer_ops.get_timer_types(provider_name=provider)
except ValueError as e:
response.status = 404
return {
"error": "Provider not found",
"message": str(e),
"provider": provider,
}
serialized = [tt.to_dict() for tt in timer_types]
response.status = 200
return {
"provider": provider,
"timer_types": serialized,
"count": len(serialized),
}
except Exception as e:
logger.error(f"Unexpected error in get_provider_timer_types: {e}")
response.status = 500
return {"error": "Internal server error", "message": str(e), "provider": provider}
# ------------------------------------------------------------------ #
# GET /api/providers/<provider>/timers #
# ------------------------------------------------------------------ #
@app.route("/api/providers/<provider>/timers", method="GET")
def get_provider_timers(provider):
"""
List timers for a specific provider.
Query parameters:
include_inactive: bool (default false) — include completed /
cancelled / error timers.
Returns:
{
"provider": "provider_name",
"timers": [ { ...Timer fields... } ],
"count": 3
}
"""
try:
include_inactive = (
request.params.get("include_inactive", "false").lower()
in ("1", "true", "yes")
)
try:
timers = manager.timer_ops.get_timers(
provider_name=provider,
include_inactive=include_inactive,
)
except ValueError as e:
response.status = 404
return {
"error": "Provider not found",
"message": str(e),
"provider": provider,
}
except Exception as e:
logger.error(f"Failed to get timers from '{provider}': {e}")
response.status = 500
return {
"error": "Failed to get timers",
"message": str(e),
"provider": provider,
}
serialized = [t.to_dict() for t in timers]
response.status = 200
return {
"provider": provider,
"timers": serialized,
"count": len(serialized),
}
except Exception as e:
logger.error(f"Unexpected error in get_provider_timers: {e}")
response.status = 500
return {"error": "Internal server error", "message": str(e), "provider": provider}
# ------------------------------------------------------------------ #
# GET /api/providers/<provider>/timers/<client_index> #
# ------------------------------------------------------------------ #
@app.route("/api/providers/<provider>/timers/<client_index:int>", method="GET")
def get_provider_timer(provider, client_index):
"""
Get a single timer by its client index.
Returns:
{ ...Timer fields... }
"""
try:
try:
timer = manager.timer_ops.get_timer(
provider_name=provider,
client_index=client_index,
)
except ValueError as e:
response.status = 404
return {
"error": "Provider not found",
"message": str(e),
"provider": provider,
}
except Exception as e:
logger.error(
f"Failed to get timer {client_index} from '{provider}': {e}"
)
response.status = 500
return {
"error": "Failed to get timer",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
if timer is None:
response.status = 404
return {
"error": "Timer not found",
"message": (
f"No timer with client_index {client_index} "
f"from '{provider}'"
),
"provider": provider,
"client_index": client_index,
}
response.status = 200
return timer.to_dict()
except Exception as e:
logger.error(f"Unexpected error in get_provider_timer: {e}")
response.status = 500
return {
"error": "Internal server error",
"message": str(e),
"provider": provider,
}
# ------------------------------------------------------------------ #
# POST /api/providers/<provider>/timers #
# ------------------------------------------------------------------ #
@app.route("/api/providers/<provider>/timers", method="POST")
def add_provider_timer(provider):
"""
Schedule a new timer on a specific provider.
Request body (JSON):
{
"title": "Tatort", // required
"timer_type_id": 1, // required
"client_channel_uid": 42,
"start_time": "2025-06-01T20:15:00",
"end_time": "2025-06-01T21:45:00",
"margin_start": 5,
"margin_end": 10,
"priority": 50,
"lifetime": 30,
...
}
Returns:
201 Created with the saved Timer in the body.
400 if the request body is invalid.
404 if the provider is not found.
409 if the provider refuses the timer (e.g. conflict).
500 on unexpected errors.
"""
try:
body = request.json
if not body:
response.status = 400
return {
"error": "Bad request",
"message": "Request body must be valid JSON",
"provider": provider,
}
# Inject provider so the Timer knows where it belongs
body.setdefault("provider", provider)
try:
timer = _timer_from_body(body)
except ValueError as e:
response.status = 400
return {
"error": "Invalid timer data",
"message": str(e),
"provider": provider,
}
try:
saved = manager.timer_ops.add_timer(
provider_name=provider, timer=timer
)
except ValueError as e:
response.status = 404
return {
"error": "Provider not found",
"message": str(e),
"provider": provider,
}
except RuntimeError as e:
response.status = 409
return {
"error": "Timer rejected by provider",
"message": str(e),
"provider": provider,
}
except Exception as e:
logger.error(f"Failed to add timer on '{provider}': {e}")
response.status = 500
return {
"error": "Failed to add timer",
"message": str(e),
"provider": provider,
}
response.status = 201
return saved.to_dict()
except Exception as e:
logger.error(f"Unexpected error in add_provider_timer: {e}")
response.status = 500
return {"error": "Internal server error", "message": str(e), "provider": provider}
# ------------------------------------------------------------------ #
# PUT /api/providers/<provider>/timers/<client_index> #
# ------------------------------------------------------------------ #
@app.route("/api/providers/<provider>/timers/<client_index:int>", method="PUT")
def update_provider_timer(provider, client_index):
"""
Update an existing timer.
The client_index from the URL takes precedence over any value in the
body — this prevents accidental cross-timer updates.
Request body (JSON): same shape as POST, all fields optional except
those the provider requires to be present on an update.
Returns:
200 OK with the updated Timer in the body.
400 if the request body is invalid.
404 if the provider or timer is not found.
409 if the provider refuses the update.
500 on unexpected errors.
"""
try:
body = request.json
if not body:
response.status = 400
return {
"error": "Bad request",
"message": "Request body must be valid JSON",
"provider": provider,
"client_index": client_index,
}
body.setdefault("provider", provider)
try:
timer = _timer_from_body(body, client_index=client_index)
except ValueError as e:
response.status = 400
return {
"error": "Invalid timer data",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
try:
updated = manager.timer_ops.update_timer(
provider_name=provider, timer=timer
)
except ValueError as e:
response.status = 404
return {
"error": "Provider not found",
"message": str(e),
"provider": provider,
}
except KeyError as e:
response.status = 404
return {
"error": "Timer not found",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
except RuntimeError as e:
response.status = 409
return {
"error": "Update refused by provider",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
except Exception as e:
logger.error(
f"Failed to update timer {client_index} on '{provider}': {e}"
)
response.status = 500
return {
"error": "Failed to update timer",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
response.status = 200
return updated.to_dict()
except Exception as e:
logger.error(f"Unexpected error in update_provider_timer: {e}")
response.status = 500
return {
"error": "Internal server error",
"message": str(e),
"provider": provider,
}
# ------------------------------------------------------------------ #
# DELETE /api/providers/<provider>/timers/<client_index> #
# ------------------------------------------------------------------ #
@app.route("/api/providers/<provider>/timers/<client_index:int>", method="DELETE")
def delete_provider_timer(provider, client_index):
"""
Delete a timer.
Query parameters:
force: bool (default false) — if the timer is currently recording,
abort the ongoing capture and delete.
Returns:
204 No Content on success.
404 if the provider or timer is not found.
409 if the provider refuses deletion and force was not set.
500 on unexpected errors.
"""
try:
force = (
request.params.get("force", "false").lower()
in ("1", "true", "yes")
)
try:
manager.timer_ops.delete_timer(
provider_name=provider,
client_index=client_index,
force_delete=force,
)
except ValueError as e:
response.status = 404
return {
"error": "Provider not found",
"message": str(e),
"provider": provider,
}
except KeyError as e:
response.status = 404
return {
"error": "Timer not found",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
except RuntimeError as e:
response.status = 409
return {
"error": "Deletion refused by provider",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
except Exception as e:
logger.error(
f"Failed to delete timer {client_index} from '{provider}': {e}"
)
response.status = 500
return {
"error": "Failed to delete timer",
"message": str(e),
"provider": provider,
"client_index": client_index,
}
# 204 No Content — no body
response.status = 204
return ""
except Exception as e:
logger.error(f"Unexpected error in delete_provider_timer: {e}")
response.status = 500
return {
"error": "Internal server error",
"message": str(e),
"provider": provider,
}
+2
View File
@@ -1587,6 +1587,7 @@ class UltimateService:
from routes.events import setup_events_routes
from routes.vod import setup_vod_routes
from routes.recordings import setup_recordings_routes
from routes.timers import setup_timers_routes
# Setup routes from separate modules
setup_provider_routes(self.app, self.manager, self)
@@ -1599,6 +1600,7 @@ class UltimateService:
setup_events_routes(self.app, self.manager, self)
setup_vod_routes(self.app, self.manager)
setup_recordings_routes(self.app, self.manager, self)
setup_timers_routes(self.app, self.manager, self)
# Core UI routes
@self.app.route("/config")