mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-18 23:22:38 +02:00
325 lines
13 KiB
Python
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,
|
|
) |