Files
script.service.ultimate/lib/streaming_providers/base/models/recording.py
T
2026-03-20 11:50:53 +01:00

325 lines
13 KiB
Python

# streaming_providers/base/models/recording.py
"""
Recording model.
A Recording represents a captured broadcast — it was recorded from a live
channel (like an Event) but is consumed as on-demand content (like a VodItem).
Because it straddles both concerns without cleanly fitting either, it inherits
directly from Content and adds recording-specific fields.
Mapping to PVR API fields is noted in inline comments where names diverge.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional
from .content import Content
class RecordingStatus(Enum):
"""Lifecycle state of the recording itself (not the broadcast)."""
PENDING = "PENDING" # scheduled to be recorded
RECORDING = "RECORDING" # currently being captured
COMPLETED = "COMPLETED" # fully captured and available
FAILED = "FAILED" # capture failed or was interrupted
DELETED = "DELETED" # marked deleted; may still be recoverable
class ChannelType(Enum):
"""Maps to PVR_RECORDING_CHANNEL_TYPE."""
UNKNOWN = "UNKNOWN"
TV = "TV"
RADIO = "RADIO"
@dataclass
class Recording(Content):
"""
A captured broadcast recording.
Inherits all streaming/DRM/manifest fields from Content so that playback
is resolved the same way as channels, events, and VOD items:
provider.get_manifest(content_id) # content_id == recording_id
provider.get_drm(content_id)
Fields are a union of:
- PVR recording API fields (see table; PVR name noted in comments)
- Fields shared with Event (timing, channel provenance)
- Fields shared with VodItem (episode/series context, playback state)
- Storage / management fields unique to recordings
"""
# ------------------------------------------------------------------
# Identity / provenance
# ------------------------------------------------------------------
# content_id (inherited) == recording_id → SetRecordingId / GetRecordingId
# The title is already `name` on Content, but PVR distinguishes a top-level
# title from an episode name within a series.
episode_name: Optional[str] = None # SetEpisodeName / GetEpisodeName
# Channel the recording was captured from
channel_name: Optional[str] = None # SetChannelName / GetChannelName
channel_uid: Optional[int] = None # SetChannelUid / GetChannelUid
channel_type: ChannelType = ChannelType.TV # SetChannelType / GetChannelType
# EPG linkage — lets the UI look up the original broadcast metadata
epg_event_id: Optional[int] = None # SetEPGEventId / GetEPGEventId (unsigned int)
# ------------------------------------------------------------------
# Timing (mirrors Event.start_time / end_time)
# ------------------------------------------------------------------
recording_time: Optional[datetime] = None # SetRecordingTime / GetRecordingTime
duration_seconds: Optional[int] = None # SetDuration / GetDuration (seconds)
first_aired: Optional[str] = None # SetFirstAired / GetFirstAired (ISO date string)
# ------------------------------------------------------------------
# Series / episode context (mirrors VodItem)
# ------------------------------------------------------------------
season_number: Optional[int] = None # SetSeriesNumber / GetSeriesNumber
episode_number: Optional[int] = None # SetEpisodeNumber / GetEpisodeNumber
series_title: Optional[str] = None # not in PVR table; added for UI grouping
series_id: Optional[str] = None # not in PVR table; added for back-navigation
release_year: Optional[int] = None # SetYear / GetYear
# ------------------------------------------------------------------
# Descriptions / metadata
# ------------------------------------------------------------------
# description (short) is inherited from Content
plot: Optional[str] = None # SetPlot / GetPlot (long description)
plot_outline: Optional[str] = None # SetPlotOutline / GetPlotOutline (short summary)
genre_description: Optional[str] = None # SetGenreDescription / GetGenreDescription
genre_type: Optional[int] = None # SetGenreType / GetGenreType (DVB genre code)
genre_sub_type: Optional[int] = None # SetGenreSubType / GetGenreSubType
# ------------------------------------------------------------------
# Visual assets
# ------------------------------------------------------------------
# logo_url (inherited from Content) ≈ icon_path
icon_path: Optional[str] = None # SetIconPath / GetIconPath
thumbnail_url: Optional[str] = None # SetThumbnailPath / GetThumbnailPath
fanart_url: Optional[str] = None # SetFanartPath / GetFanartPath
# ------------------------------------------------------------------
# Playback state (unique to recordings; events and VOD don't track this)
# ------------------------------------------------------------------
play_count: int = 0 # SetPlayCount / GetPlayCount
last_played_position: int = 0 # SetLastPlayedPosition / GetLastPlayedPosition (seconds)
# ------------------------------------------------------------------
# Storage / management
# ------------------------------------------------------------------
directory: Optional[str] = None # SetDirectory / GetDirectory
size_in_bytes: Optional[int] = None # SetSizeInBytes / GetSizeInBytes
priority: Optional[int] = None # SetPriority / GetPriority
lifetime: Optional[int] = None # SetLifetime / GetLifetime (days; 0 = keep forever)
flags: Optional[str] = None # SetFlags / GetFlags
# Provider identification (separate from the streaming provider on Content)
client_provider_uid: Optional[int] = None # SetClientProviderUid / GetClientProviderUid
provider_name: Optional[str] = None # SetProviderName / GetProviderName
# ------------------------------------------------------------------
# Status
# ------------------------------------------------------------------
status: RecordingStatus = RecordingStatus.COMPLETED
# is_deleted is derived from status but exposed as a convenience flag to
# match the PVR API; setting it syncs the status enum.
_is_deleted: bool = field(default=False, repr=False)
# ------------------------------------------------------------------
# Post-init
# ------------------------------------------------------------------
def __post_init__(self):
# Recordings are always played on-demand
self.mode = "vod"
self.on_demand = True
# Sync is_deleted → status
if self._is_deleted and self.status != RecordingStatus.DELETED:
self.status = RecordingStatus.DELETED
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def recording_id(self) -> str:
"""Semantic alias for content_id — maps to PVR GetRecordingId."""
return self.content_id
@recording_id.setter
def recording_id(self, value: str):
self.content_id = value
@property
def is_deleted(self) -> bool:
"""SetIsDeleted / GetIsDeleted — synced with RecordingStatus.DELETED."""
return self.status == RecordingStatus.DELETED
@is_deleted.setter
def is_deleted(self, value: bool):
if value:
self.status = RecordingStatus.DELETED
elif self.status == RecordingStatus.DELETED:
self.status = RecordingStatus.COMPLETED
@property
def is_episode(self) -> bool:
return self.season_number is not None or self.episode_number is not None
@property
def duration_minutes(self) -> Optional[int]:
if self.duration_seconds is not None:
return self.duration_seconds // 60
return None
@property
def is_watched(self) -> bool:
"""True if the recording has been played at least once."""
return self.play_count > 0
@property
def is_in_progress(self) -> bool:
"""True if the recording is currently being captured."""
return self.status == RecordingStatus.RECORDING
# ------------------------------------------------------------------
# Serialisation
# ------------------------------------------------------------------
def to_dict(self) -> Dict:
result = super().to_dict()
result.update({
# Identity
"EpisodeName": self.episode_name,
"ChannelName": self.channel_name,
"ChannelUid": self.channel_uid,
"ChannelType": self.channel_type.value,
"EpgEventId": self.epg_event_id,
# Timing
"RecordingTime": self.recording_time.isoformat() if self.recording_time else None,
"DurationSeconds": self.duration_seconds,
"DurationMinutes": self.duration_minutes,
"FirstAired": self.first_aired,
# Series / episode
"SeasonNumber": self.season_number,
"EpisodeNumber": self.episode_number,
"SeriesTitle": self.series_title,
"SeriesId": self.series_id,
"ReleaseYear": self.release_year,
# Descriptions
"Plot": self.plot,
"PlotOutline": self.plot_outline,
"GenreDescription": self.genre_description,
"GenreType": self.genre_type,
"GenreSubType": self.genre_sub_type,
# Visual
"IconPath": self.icon_path,
"ThumbnailUrl": self.thumbnail_url,
"FanartUrl": self.fanart_url,
# Playback state
"PlayCount": self.play_count,
"LastPlayedPosition": self.last_played_position,
# Storage
"Directory": self.directory,
"SizeInBytes": self.size_in_bytes,
"Priority": self.priority,
"Lifetime": self.lifetime,
"Flags": self.flags,
"ClientProviderUid": self.client_provider_uid,
"ProviderName": self.provider_name,
# Status
"Status": self.status.value,
"IsDeleted": self.is_deleted,
})
return result
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
def validate(self) -> List[str]:
warnings = []
if not self.manifest and not self.manifest_script:
warnings.append("No manifest URL or manifest script provided")
if self.license_url and not self.drm_config:
warnings.append("License URL provided but no DRM configuration")
if self.duration_seconds is not None and self.duration_seconds <= 0:
warnings.append("duration_seconds must be positive")
if self.size_in_bytes is not None and self.size_in_bytes < 0:
warnings.append("size_in_bytes must not be negative")
if self.lifetime is not None and self.lifetime < 0:
warnings.append("lifetime must be 0 (keep forever) or a positive number of days")
if self.release_year is not None and not (1888 <= self.release_year <= 2100):
warnings.append(f"Unusual release_year: {self.release_year}")
return warnings
# ------------------------------------------------------------------
# Factory methods
# ------------------------------------------------------------------
@classmethod
def create_completed(
cls,
name: str,
recording_id: str,
provider: str,
recording_time: Optional[datetime] = None,
duration_seconds: Optional[int] = None,
**kwargs,
) -> "Recording":
"""Create a fully captured recording ready for playback."""
return cls(
name=name,
content_id=recording_id,
provider=provider,
status=RecordingStatus.COMPLETED,
recording_time=recording_time,
duration_seconds=duration_seconds,
**kwargs,
)
@classmethod
def create_in_progress(
cls,
name: str,
recording_id: str,
provider: str,
recording_time: Optional[datetime] = None,
**kwargs,
) -> "Recording":
"""Create a recording that is currently being captured (live recording)."""
return cls(
name=name,
content_id=recording_id,
provider=provider,
status=RecordingStatus.RECORDING,
recording_time=recording_time,
**kwargs,
)
@classmethod
def create_episode_recording(
cls,
name: str,
recording_id: str,
provider: str,
season_number: int,
episode_number: int,
series_title: Optional[str] = None,
**kwargs,
) -> "Recording":
"""Create a recording of a specific series episode."""
return cls(
name=name,
content_id=recording_id,
provider=provider,
status=RecordingStatus.COMPLETED,
season_number=season_number,
episode_number=episode_number,
series_title=series_title,
**kwargs,
)