-
-
Notifications
You must be signed in to change notification settings - Fork 61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: adding tvmaze for accurate release time detection #923
base: main
Are you sure you want to change the base?
Changes from 11 commits
fa5173e
3c5fc47
896e316
f42dfdc
fbce61e
46f4e45
049ecdc
1ad3dc9
1cb35ca
ad058d8
a2b3290
d1e2a84
a1ef64c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
"""TVMaze API client module""" | ||
|
||
from datetime import datetime, timedelta, timezone | ||
from typing import Optional, Union | ||
|
||
from loguru import logger | ||
from requests import Session | ||
|
||
from program.media.item import Episode, MediaItem | ||
from program.utils.request import ( | ||
BaseRequestHandler, | ||
HttpMethod, | ||
ResponseType, | ||
create_service_session, | ||
get_cache_params, | ||
get_rate_limit_params, | ||
) | ||
|
||
class TVMazeAPIError(Exception): | ||
"""Base exception for TVMaze API related errors""" | ||
|
||
class TVMazeRequestHandler(BaseRequestHandler): | ||
def __init__(self, session: Session, response_type=ResponseType.SIMPLE_NAMESPACE, request_logging: bool = False): | ||
super().__init__(session, response_type=response_type, custom_exception=TVMazeAPIError, request_logging=request_logging) | ||
|
||
def execute(self, method: HttpMethod, endpoint: str, **kwargs): | ||
return super()._request(method, endpoint, **kwargs) | ||
|
||
class TVMazeAPI: | ||
"""Handles TVMaze API communication""" | ||
BASE_URL = "https://api.tvmaze.com" | ||
|
||
def __init__(self): | ||
rate_limit_params = get_rate_limit_params(max_calls=20, period=10) | ||
tvmaze_cache = get_cache_params("tvmaze", 86400) | ||
session = create_service_session( | ||
rate_limit_params=rate_limit_params, | ||
use_cache=True, | ||
cache_params=tvmaze_cache | ||
) | ||
self.request_handler = TVMazeRequestHandler(session) | ||
|
||
# Obtain the local timezone | ||
self.local_tz = datetime.now().astimezone().tzinfo | ||
|
||
def get_show_by_imdb_id(self, imdb_id: str) -> Optional[dict]: | ||
"""Get show information by IMDb ID""" | ||
if not imdb_id: | ||
return None | ||
|
||
url = f"{self.BASE_URL}/lookup/shows" | ||
try: | ||
response = self.request_handler.execute( | ||
HttpMethod.GET, | ||
url, | ||
params={"imdb": imdb_id} | ||
) | ||
if response.is_ok and response.data: | ||
logger.debug(f"Found TVMaze show for IMDb ID {imdb_id}: ID={getattr(response.data, 'id', None)}") | ||
return response.data | ||
else: | ||
logger.debug(f"No TVMaze show found for IMDb ID: {imdb_id}") | ||
return None | ||
except Exception as e: | ||
logger.error(f"Error getting TVMaze show for IMDb ID {imdb_id}: {e}") | ||
return None | ||
|
||
def get_episode_by_number(self, show_id: int, season: int, episode: int) -> Optional[Union[datetime, bool]]: | ||
"""Get episode information by show ID and episode number. | ||
Returns: | ||
- datetime: If episode exists and has an air date | ||
- False: If episode exists but has no air date | ||
- None: If episode doesn't exist (404) or error occurred | ||
""" | ||
if not show_id or not season or not episode: | ||
return None | ||
|
||
url = f"{self.BASE_URL}/shows/{show_id}/episodebynumber" | ||
try: | ||
response = self.request_handler.execute( | ||
HttpMethod.GET, | ||
url, | ||
params={ | ||
"season": season, | ||
"number": episode | ||
} | ||
) | ||
|
||
# Don't log 404s as they're expected for future/nonexistent episodes | ||
if response.status_code == 404: | ||
return None | ||
|
||
if not response.is_ok or not response.data: | ||
logger.error(f"Invalid TVMaze response for S{season:02d}E{episode:02d} (show_id={show_id})") | ||
return None | ||
|
||
# Episode exists but might not have an air date | ||
air_date = self._parse_air_date(response.data) | ||
return air_date if air_date else False | ||
|
||
except Exception as e: | ||
# Only log unexpected errors, not 404s | ||
if "404" not in str(e): | ||
logger.error(f"TVMaze API error for S{season:02d}E{episode:02d} (show_id={show_id})") | ||
return None | ||
|
||
def _parse_air_date(self, episode_data) -> Optional[datetime]: | ||
"""Parse episode air date from TVMaze response""" | ||
if airstamp := getattr(episode_data, "airstamp", None): | ||
try: | ||
# Handle both 'Z' suffix and explicit timezone | ||
timestamp = airstamp.replace('Z', '+00:00') | ||
if '.' in timestamp: | ||
# Strip milliseconds but preserve timezone | ||
parts = timestamp.split('.') | ||
base = parts[0] | ||
tz = parts[1][parts[1].find('+'):] | ||
timestamp = base + tz if '+' in parts[1] else base + '+00:00' | ||
elif not ('+' in timestamp or '-' in timestamp): | ||
# Add UTC timezone if none specified | ||
timestamp = timestamp + '+00:00' | ||
# Convert to user's timezone | ||
utc_dt = datetime.fromisoformat(timestamp) | ||
return utc_dt.astimezone(self.local_tz) | ||
except (ValueError, AttributeError) as e: | ||
logger.error(f"Failed to parse TVMaze airstamp: {airstamp} - {e}") | ||
|
||
try: | ||
if airdate := getattr(episode_data, "airdate", None): | ||
if airtime := getattr(episode_data, "airtime", None): | ||
# Combine date and time with UTC timezone first | ||
dt_str = f"{airdate}T{airtime}+00:00" | ||
utc_dt = datetime.fromisoformat(dt_str) | ||
# Convert to user's timezone | ||
return utc_dt.astimezone(self.local_tz) | ||
# If we only have a date, set time to midnight in user's timezone | ||
local_midnight = datetime.fromisoformat(f"{airdate}T00:00:00").replace(tzinfo=self.local_tz) | ||
return local_midnight | ||
except (ValueError, AttributeError) as e: | ||
logger.error(f"Failed to parse TVMaze air date/time: {airdate}/{getattr(episode_data, 'airtime', None)} - {e}") | ||
|
||
return None | ||
|
||
def get_episode_release_time(self, episode: Episode) -> Optional[Union[datetime, bool]]: | ||
"""Get episode release time from TVMaze. | ||
Returns: | ||
- datetime: If episode exists and has an air date | ||
- False: If episode exists but has no air date | ||
- None: If episode doesn't exist (404) or error occurred | ||
""" | ||
if not episode or not episode.parent or not episode.parent.parent: | ||
return None | ||
|
||
show = episode.parent.parent | ||
if not hasattr(show, 'tvmaze_id') or not show.tvmaze_id: | ||
# Try to get TVMaze ID using IMDb ID | ||
show_data = self.get_show_by_imdb_id(show.imdb_id) | ||
if show_data: | ||
show.tvmaze_id = getattr(show_data, 'id', None) | ||
logger.debug(f"Set TVMaze ID {show.tvmaze_id} for show {show.title} (IMDb: {show.imdb_id})") | ||
else: | ||
logger.debug(f"Could not find TVMaze ID for show {show.title} (IMDb: {show.imdb_id})") | ||
return None | ||
|
||
if not show.tvmaze_id: | ||
logger.debug(f"No valid TVMaze ID for show {show.title}") | ||
return None | ||
|
||
# Log what we're checking | ||
logger.debug(f"Found regular schedule time for {show.title} S{episode.parent.number:02d}E{episode.number:02d}: {episode.aired_at}") | ||
|
||
# Get episode by number | ||
try: | ||
logger.debug(f"Checking streaming schedule for {show.title} S{episode.parent.number:02d}E{episode.number:02d} (Show ID: {show.tvmaze_id})") | ||
result = self.get_episode_by_number(show.tvmaze_id, episode.parent.number, episode.number) | ||
|
||
if isinstance(result, datetime): | ||
logger.debug(f"Final release time for {show.title} S{episode.parent.number:02d}E{episode.number:02d}: {result}") | ||
return result | ||
elif result is False: | ||
logger.debug(f"Episode exists in TVMaze but has no air date: {show.title} S{episode.parent.number:02d}E{episode.number:02d}") | ||
return False | ||
else: | ||
logger.debug(f"Episode not found in TVMaze: {show.title} S{episode.parent.number:02d}E{episode.number:02d}") | ||
return None | ||
|
||
except Exception as e: | ||
logger.error(f"Unexpected error getting TVMaze time for {show.title} S{episode.parent.number:02d}E{episode.number:02d}: {e}") | ||
return None | ||
|
||
return None |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -142,8 +142,11 @@ def __generate_composite_key(item: dict) -> str | None: | |||||||||||||||||||||||||||||||||||||||||||||||||||
item_type = item.get("type", "unknown") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return f"{item_type}_{trakt_id}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def store_state(self, given_state=None) -> tuple[States, States]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
def store_state(self, given_state: States = None) -> tuple[States, States]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
"""Store the state of the item.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
if self.last_state == States.Completed: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+145
to
+149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Reconsider early return in The early return in Consider adjusting the logic to allow state updates when appropriate. |
||||||||||||||||||||||||||||||||||||||||||||||||||||
previous_state = self.last_state | ||||||||||||||||||||||||||||||||||||||||||||||||||||
new_state = given_state if given_state else self._determine_state() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
if previous_state and previous_state != new_state: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -174,9 +177,18 @@ def blacklist_stream(self, stream: Stream): | |||||||||||||||||||||||||||||||||||||||||||||||||||
@property | ||||||||||||||||||||||||||||||||||||||||||||||||||||
def is_released(self) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
"""Check if an item has been released.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
if self.aired_at and self.aired_at <= datetime.now(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return True | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||
if not self.aired_at: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
# Ensure both datetimes are timezone-aware for comparison | ||||||||||||||||||||||||||||||||||||||||||||||||||||
now = datetime.now().astimezone() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
aired_at = self.aired_at | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
# Make aired_at timezone-aware if it isn't already | ||||||||||||||||||||||||||||||||||||||||||||||||||||
if aired_at.tzinfo is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
aired_at = aired_at.replace(tzinfo=now.tzinfo) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
return aired_at <= now | ||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+180
to
+191
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Properly handle naive Directly setting the timezone on a naive Apply this diff to correctly localize + from datetime import timezone
...
if aired_at.tzinfo is None:
- aired_at = aired_at.replace(tzinfo=now.tzinfo)
+ aired_at = aired_at.replace(tzinfo=timezone.utc).astimezone(now.tzinfo) Alternatively, if 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
@property | ||||||||||||||||||||||||||||||||||||||||||||||||||||
def state(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -391,6 +403,9 @@ def _reset(self): | |||||||||||||||||||||||||||||||||||||||||||||||||||
def log_string(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.title or self.id | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __repr__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove this, its not needed as item.log_string already does it for us |
||||||||||||||||||||||||||||||||||||||||||||||||||||
return f"MediaItem:{self.log_string}:{self.state.name}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
@property | ||||||||||||||||||||||||||||||||||||||||||||||||||||
def collection(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.parent.collection if self.parent else self.id | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -414,12 +429,10 @@ def __init__(self, item): | |||||||||||||||||||||||||||||||||||||||||||||||||||
self.file = item.get("file", None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
super().__init__(item) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __repr__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return f"Movie:{self.log_string}:{self.state.name}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __hash__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return super().__hash__() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
class Show(MediaItem): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
"""Show class""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
__tablename__ = "Show" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -475,12 +488,6 @@ def store_state(self, given_state: States =None) -> None: | |||||||||||||||||||||||||||||||||||||||||||||||||||
season.store_state(given_state) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
super().store_state(given_state) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __repr__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return f"Show:{self.log_string}:{self.state.name}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __hash__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return super().__hash__() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def copy(self, other): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
super(Show, self).copy(other) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
self.seasons = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -583,12 +590,6 @@ def _determine_state(self): | |||||||||||||||||||||||||||||||||||||||||||||||||||
def is_released(self) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return any(episode.is_released for episode in self.episodes) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __repr__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return f"Season:{self.number}:{self.state.name}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __hash__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return super().__hash__() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def copy(self, other, copy_parent=True): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
super(Season, self).copy(other) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
for episode in other.episodes: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -653,9 +654,6 @@ def __init__(self, item): | |||||||||||||||||||||||||||||||||||||||||||||||||||
if self.parent and isinstance(self.parent, Season): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
self.is_anime = self.parent.parent.is_anime | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __repr__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return f"Episode:{self.number}:{self.state.name}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
def __hash__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||
return super().__hash__() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Add error handling to the PostgreSQL event listener.
The event listener should handle potential database errors to prevent silent failures.
Consider wrapping the cursor operations in a try-finally block:
📝 Committable suggestion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please remove all the env.py changes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dreulavelle, understood. I'll disregard the changes in
env.py
.✏️ Learnings added
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.