Skip to content
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

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
6 changes: 6 additions & 0 deletions src/program/apis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .overseerr_api import OverseerrAPI, OverseerrAPIError
from .plex_api import PlexAPI, PlexAPIError
from .trakt_api import TraktAPI, TraktAPIError
from .tvmaze_api import TVMazeAPI, TVMazeAPIError


def bootstrap_apis():
Expand All @@ -15,6 +16,7 @@ def bootstrap_apis():
__setup_mdblist()
__setup_overseerr()
__setup_listrr()
__setup_tvmaze()
dreulavelle marked this conversation as resolved.
Show resolved Hide resolved

def __setup_trakt():
traktApi = TraktAPI(settings_manager.settings.content.trakt)
Expand Down Expand Up @@ -43,3 +45,7 @@ def __setup_listrr():
return
listrrApi = ListrrAPI(settings_manager.settings.content.listrr.api_key)
di[ListrrAPI] = listrrApi

def __setup_tvmaze():
tvmazeApi = TVMazeAPI()
di[TVMazeAPI] = tvmazeApi
34 changes: 31 additions & 3 deletions src/program/apis/trakt_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import re
from datetime import datetime
from datetime import datetime, timedelta, timezone
import time
from zoneinfo import ZoneInfo
from tzlocal import get_localzone
from types import SimpleNamespace
from typing import List, Optional, Union
from urllib.parse import urlencode
Expand Down Expand Up @@ -58,6 +61,13 @@ def __init__(self, settings: TraktModel):
}
session.headers.update(self.headers)
self.request_handler = TraktRequestHandler(session)

# Get the system's local timezone
try:
self.local_tz = get_localzone()
except Exception:
self.local_tz = timezone.utc
logger.warning("Could not determine system timezone, using UTC")

def validate(self):
return self.request_handler.execute(HttpMethod.GET, f"{self.BASE_URL}/lists/2")
Expand Down Expand Up @@ -360,7 +370,25 @@ def _get_imdb_id_from_list(self, namespaces: List[SimpleNamespace], id_type: str
def _get_formatted_date(self, data, item_type: str) -> Optional[datetime]:
"""Get the formatted aired date from the data."""
if item_type in ["show", "season", "episode"] and (first_aired := getattr(data, "first_aired", None)):
return datetime.strptime(first_aired, "%Y-%m-%dT%H:%M:%S.%fZ")
try:
# First try with milliseconds
utc_dt = datetime.strptime(first_aired, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
return utc_dt.astimezone(self.local_tz)
except ValueError:
try:
# Try without milliseconds
utc_dt = datetime.strptime(first_aired, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
return utc_dt.astimezone(self.local_tz)
except ValueError as e:
logger.error(f"Failed to parse Trakt air date: {first_aired} - {e}")
return None

if item_type == "movie" and (released := getattr(data, "released", None)):
return datetime.strptime(released, "%Y-%m-%d")
try:
# For movies, Trakt provides date only, set to midnight in user's timezone
local_midnight = datetime.strptime(released, "%Y-%m-%d").replace(tzinfo=self.local_tz)
return local_midnight
except ValueError as e:
logger.error(f"Failed to parse Trakt release date: {released} - {e}")
return None
return None
191 changes: 191 additions & 0 deletions src/program/apis/tvmaze_api.py
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
42 changes: 20 additions & 22 deletions src/program/media/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reconsider early return in store_state to allow necessary state updates

The early return in store_state when self.last_state == States.Completed prevents any further state updates. This might be problematic if you need to update the state after completion, such as in cases of reprocessing or error corrections.

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:
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Properly handle naive aired_at datetime objects to ensure accurate timezone conversions

Directly setting the timezone on a naive datetime object using replace(tzinfo=...) assumes that the original datetime is in the local timezone, which may not be accurate. This can lead to incorrect release status determination.

Apply this diff to correctly localize aired_at assuming it is in UTC:

+    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 aired_at is intended to be in the local timezone, consider using a method that properly localizes naive datetime objects without assuming they are in UTC.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
if not self.aired_at:
return False
# Ensure both datetimes are timezone-aware for comparison
from datetime import timezone
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=timezone.utc).astimezone(now.tzinfo)
return aired_at <= now


@property
def state(self):
Expand Down Expand Up @@ -391,6 +403,9 @@ def _reset(self):
def log_string(self):
return self.title or self.id

def __repr__(self):
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand All @@ -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"
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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__()

Expand Down
Loading