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: Active ongoing episode releasing on the time of airing #942

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

wolfemir
Copy link
Collaborator

@wolfemir wolfemir commented Dec 15, 2024

Pull Request Check List

Resolves: #issue-number-here

  • Added tests for changed code.
  • Updated documentation for changed code.

Description:

Summary by CodeRabbit

  • New Features

    • Introduced a custom logging level for better tracking.
    • Added a new API client for fetching show information from TVMaze.
    • Enhanced date handling and error logging in the Trakt API.
    • New methods added to the MediaItem class for improved media management.
    • Implemented scheduling capabilities in the Program class to manage release times.
    • New field for release delay added to the Trakt model.
  • Bug Fixes

    • Improved error handling for API requests and date parsing.
    • Enhanced retrieval logic to handle multiple media items and log duplicates.
  • Documentation

    • Updated docstrings for clarity in API methods.
  • Deprecations

    • Marked certain fields as deprecated in various configuration models.

Copy link
Contributor

coderabbitai bot commented Dec 15, 2024

Walkthrough

This pull request introduces comprehensive updates across multiple modules in the program, focusing on enhancing API interactions, logging, and media item handling. The changes include adding a new TVMaze API client, improving date and timezone management, introducing a custom logging level, and refining the program's scheduling and release tracking mechanisms. The modifications aim to provide more robust and flexible media item tracking with improved error handling and logging capabilities.

Changes

File Changes
src/program/__init__.py - Updated module docstring
- Added loguru custom log level "RELEASE"
src/program/apis/__init__.py - Added TVMaze API setup function
- Updated bootstrap_apis to include TVMaze initialization
src/program/apis/trakt_api.py - Enhanced date parsing and handling
- Improved error logging and timezone management
src/program/apis/tvmaze_api.py - New API client for TVMaze
- Methods to fetch show information by IMDB ID
src/program/media/item.py - Added timezone-aware is_released property
- New methods: get_top_title, get_top_imdb_id, get_aliases
- Implemented __hash__ method
- Enhanced reset method
src/program/program.py - Added scheduled_releases dictionary
- Integrated BackgroundScheduler
- Updated _update_ongoing method
- Added _update_item_state method
src/program/settings/models.py - Added release_delay_minutes to TraktModel
- Deprecated ratelimit fields in various configs
src/routers/secure/items.py - Updated get_item to fetch all matching items
- Improved error handling in remove_item

Possibly related PRs

  • feat: integrate dependency injection with kink library #859: The main PR introduces logging functionality and modifies the src/program/__init__.py file, while this retrieved PR integrates dependency injection across various modules, including src/program/apis/__init__.py, which may affect how logging is handled in those APIs.
  • fix: re-check ongoing/unreleased items #880: The main PR modifies the _update_ongoing method in the Program class, which is also referenced in this retrieved PR that updates the same method's logic and logging, indicating a direct relationship in the changes made to the Program class.

Suggested reviewers

  • Gaisberg

Poem

🐰 Hoppity hop, through code we leap,
APIs dancing, secrets we keep
Logs shine bright in magenta glow
Releases tracked, now smooth they flow
A rabbit's code, precise and neat! 🚀

Finishing Touches

  • 📝 Generate Docstrings

Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR. (Beta)
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🔭 Outside diff range comments (1)
src/program/apis/trakt_api.py (1)

Line range hint 290-291: Simplify nested if statements into a single condition

At lines 290-291, the nested if statements can be combined to improve clarity.

Apply this diff:

- if item.type == "episode":
-     if isinstance(item, Episode):
+ if item.type == "episode" and isinstance(item, Episode):
🧹 Nitpick comments (11)
src/program/apis/tvmaze_api.py (2)

171-172: Catch specific exceptions instead of Exception

Catching the broad Exception class can mask unexpected errors. It's better to catch specific exceptions that you anticipate.

Consider updating the exception handling:

- except Exception as e:
+ except (ValueError, TypeError) as e:

Replace ValueError, TypeError with the specific exceptions that might be raised.


182-184: Avoid catching broad exceptions

Similar to earlier, catching the general Exception at lines 182-184 can obscure issues. Catch specific exceptions to handle known error conditions.

Update the exception handling code:

- except Exception as e:
+ except (HTTPError, ValueError) as e:

Replace HTTPError, ValueError with applicable exceptions.

src/program/apis/trakt_api.py (2)

362-395: Ensure timezone-awareness when parsing dates

In the _get_formatted_date method, consider handling timezone information more carefully to avoid potential bugs due to naive datetime objects.

Ensure that all datetime objects are timezone-aware and handle cases where the timezone might not be specified.


Line range hint 354-354: Remove unused loop variable aired_at

The loop control variable aired_at is not used within the loop body at line 354. This can cause confusion.

Consider removing the unused variable:

- for item, aired_at in items:
+ for item, _ in items:

Or, if aired_at is not needed at all:

- for item, aired_at in items:
+ for item in items:
src/program/program.py (3)

220-263: Ensure consistent timezone handling

In the _update_ongoing method, consider standardizing the use of timezone-aware datetime objects to prevent potential issues with time comparisons.

Ensure that all datetime objects (current_time, trakt_time) are timezone-aware and use the same timezone for accurate comparisons.


354-355: Remove unused variable aired_at

The variable aired_at is not used within the loop at lines 354-355. This may cause confusion.

Update the loop to remove the unused variable:

- for item, aired_at in items:
+ for item, _ in items:

Or:

- for item, aired_at in items:
+ for item in items:
🧰 Tools
🪛 Ruff (0.8.2)

354-354: Loop control variable aired_at not used within loop body

(B007)


366-368: Clarify logging message when updating item states

At lines 366-368, the logging message states that item states have been updated, but the counter may not reflect the actual number of updated items due to exceptions.

Consider updating the logging logic to accurately reflect the number of items whose states were successfully updated.

src/program/media/item.py (1)

668-673: Use consistent timezone handling in Season.is_released

Similar to MediaItem, the is_released property in Season should handle timezone-aware datetimes consistently.

Ensure that current_time and self.aired_at are both timezone-aware and in the same timezone.

src/program/__init__.py (1)

1-1: Consider expanding the module docstring

The docstring could be more descriptive about the module's purpose and functionality, especially given the new logging features.

-"""Program module."""
+"""Program module providing core functionality and logging configuration.
+
+This module initializes custom logging levels and exports core program entities.
+"""
src/program/apis/__init__.py (1)

10-10: Consider grouping related API imports

The TVMaze API import should be grouped with other API imports at the top of the file.

-from program.apis.tvmaze_api import TVMazeAPI
+from .tvmaze_api import TVMazeAPI, TVMazeAPIError
src/program/settings/models.py (1)

Line range hint 191-191: Document migration path for deprecated ratelimit fields

The deprecation warnings should include guidance on the migration path for users.

-    ratelimit: bool = Field(default=True, deprecated=deprecation_warning)
+    ratelimit: bool = Field(
+        default=True,
+        deprecated="This field has been deprecated and will be removed in v2.0.0. "
+                  "Rate limiting is now handled automatically by the API client."
+    )

This change should be applied to all three occurrences of the deprecated ratelimit field.

Also applies to: 201-201, 211-211

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8a5e849 and 01f2b38.

📒 Files selected for processing (7)
  • src/program/__init__.py (1 hunks)
  • src/program/apis/__init__.py (2 hunks)
  • src/program/apis/trakt_api.py (2 hunks)
  • src/program/apis/tvmaze_api.py (1 hunks)
  • src/program/media/item.py (4 hunks)
  • src/program/program.py (3 hunks)
  • src/program/settings/models.py (1 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/program.py

290-291: Use a single if statement instead of nested if statements

(SIM102)


354-354: Loop control variable aired_at not used within loop body

(B007)

src/program/apis/tvmaze_api.py

154-155: Use a single if statement instead of nested if statements

(SIM102)


165-167: Use a single if statement instead of nested if statements

(SIM102)

src/program/media/item.py

402-402: Redefinition of unused get_top_title from line 322

(F811)


411-411: Redefinition of unused get_top_imdb_id from line 331

(F811)


419-419: Redefinition of unused get_aliases from line 339

(F811)


428-428: Redefinition of unused __hash__ from line 348

(F811)


431-431: Redefinition of unused reset from line 351

(F811)

🔇 Additional comments (3)
src/program/apis/trakt_api.py (1)

404-405: Avoid potential off-by-one error when scheduling midnight update

When calculating the next midnight time, ensure that the calculation correctly handles edge cases, such as when the current time is exactly at midnight.

Run the following script to verify the correctness of the midnight scheduling logic:

This script will help ensure that the scheduling logic always sets the next midnight correctly.

src/program/media/item.py (1)

178-187: ⚠️ Potential issue

Handle timezone-aware datetime objects

In the is_released property at lines 178-187, ensure that aired_at and now are both timezone-aware to prevent incorrect time comparisons.

Apply this diff to ensure consistent timezone handling:

- now = datetime.now(tz=self.aired_at.tzinfo)
+ now = datetime.now(ZoneInfo("UTC"))

Alternatively, ensure both aired_at and now are in the same timezone.

Likely invalid or redundant comment.

src/program/settings/models.py (1)

Line range hint 191-191: Verify usage of deprecated ratelimit fields

Let's ensure the deprecated fields aren't used in new code.

Also applies to: 201-201, 211-211

✅ Verification successful

Let me analyze the usage patterns of ratelimit to determine if there are any deprecated field usages in new code.


No deprecated ratelimit field usage found

The analysis shows that all ratelimit usages across the scrapers are consistent and follow the current pattern:

  • Used as a boolean flag to enable/disable rate limiting
  • Properly validated as boolean in configuration checks
  • Consistently used with get_rate_limit_params() for session creation
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check for usage of ratelimit in API implementations
rg -l "\.ratelimit" --type py

# Check for any new code using ratelimit
git diff origin/main..HEAD | rg "\+.*ratelimit"

Length of output: 420


Script:

#!/bin/bash
# Check the actual usage context of ratelimit in the files
for file in src/program/services/scrapers/*.py; do
  echo "=== $file ==="
  rg "\.ratelimit" -B 2 -A 2 "$file"
done

# Check if there are any new ratelimit-related changes in the PR
git diff origin/main..HEAD | rg "ratelimit" -B 2 -A 2

Length of output: 6771

src/program/apis/tvmaze_api.py Outdated Show resolved Hide resolved
Comment on lines 165 to 167
elif air_time > current_time:
# If we haven't found any future episode yet, or this one airs sooner
if not next_episode or air_time < next_episode:
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

Combine nested conditions into a single if statement

The nested if statements at lines 165-167 can be merged for clearer logic flow.

Apply this diff:

- elif air_time > current_time:
-     if not next_episode or air_time < next_episode:
-         next_episode = air_time
+ elif air_time > current_time and (not next_episode or air_time < next_episode):
+     next_episode = air_time
📝 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
elif air_time > current_time:
# If we haven't found any future episode yet, or this one airs sooner
if not next_episode or air_time < next_episode:
elif air_time > current_time and (not next_episode or air_time < next_episode):
next_episode = air_time
🧰 Tools
🪛 Ruff (0.8.2)

165-167: Use a single if statement instead of nested if statements

(SIM102)

Comment on lines +428 to +430
def __hash__(self):
return hash(self.id)

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

Address method redefinitions

There are multiple redefinitions of the __hash__ method at lines 348 and 428-430.

Ensure that the __hash__ method is only defined once in the inheritance hierarchy to prevent unexpected behavior.

Consider removing the duplicate method definition.

🧰 Tools
🪛 Ruff (0.8.2)

428-428: Redefinition of unused __hash__ from line 348

(F811)

Comment on lines +431 to +446
def reset(self):
"""Reset item attributes."""
if self.type == "show":
for season in self.seasons:
for episode in season.episodes:
episode._reset()
season._reset()
elif self.type == "season":
for episode in self.episodes:
episode._reset()
self._reset()
if self.title:
self.store_state(States.Indexed)
else:
self.store_state(States.Requested)

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

Address method redefinitions

The reset method is redefined at lines 351 and 431-446.

To avoid confusion and potential bugs, ensure that method definitions are not duplicated. Consolidate the method into a single definition or properly override it if necessary.

🧰 Tools
🪛 Ruff (0.8.2)

431-431: Redefinition of unused reset from line 351

(F811)

Comment on lines +8 to +9
# Add custom log levels
logger.level("RELEASE", no=35, color="<magenta>")
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

Add error handling for logger configuration

The logger configuration should be wrapped in a try-catch block to handle potential initialization failures gracefully.

 # Add custom log levels
-logger.level("RELEASE", no=35, color="<magenta>")
+try:
+    logger.level("RELEASE", no=35, color="<magenta>")
+except Exception as e:
+    logger.error(f"Failed to configure RELEASE log level: {e}")
📝 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
# Add custom log levels
logger.level("RELEASE", no=35, color="<magenta>")
# Add custom log levels
try:
logger.level("RELEASE", no=35, color="<magenta>")
except Exception as e:
logger.error(f"Failed to configure RELEASE log level: {e}")

src/program/apis/__init__.py Show resolved Hide resolved
@@ -173,6 +173,7 @@ class TraktModel(Updatable):
most_watched_period: str = "weekly"
most_watched_count: int = 10
update_interval: int = 86400
release_delay_minutes: int = Field(default=60, description="Number of minutes to delay the state change after release")
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

Add validation for release_delay_minutes

The release delay should have reasonable minimum and maximum values to prevent configuration errors.

-    release_delay_minutes: int = Field(default=60, description="Number of minutes to delay the state change after release")
+    release_delay_minutes: int = Field(
+        default=60,
+        description="Number of minutes to delay the state change after release",
+        ge=0,
+        le=1440,  # Max 24 hours
+    )
📝 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
release_delay_minutes: int = Field(default=60, description="Number of minutes to delay the state change after release")
release_delay_minutes: int = Field(
default=60,
description="Number of minutes to delay the state change after release",
ge=0,
le=1440, # Max 24 hours
)

from program.program import Event, Program # noqa: F401

# Add custom log levels
logger.level("RELEASE", no=35, color="<magenta>")
Copy link
Member

Choose a reason for hiding this comment

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

this belongs in logging.py, you dont need to change this file :)

@@ -7,18 +7,19 @@
from .overseerr_api import OverseerrAPI, OverseerrAPIError
from .plex_api import PlexAPI, PlexAPIError
from .trakt_api import TraktAPI, TraktAPIError

from program.apis.tvmaze_api import TVMazeAPI
Copy link
Member

Choose a reason for hiding this comment

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

from .tvmaze_api import TVMazeAPI

Also be sure to make a custom exception for TVMaze as well. Follow the same concepts as the other api modules do in this api's dir

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🔭 Outside diff range comments (1)
src/program/apis/tvmaze_api.py (1)

Line range hint 220-377: Consider breaking down the _update_ongoing method into smaller functions.

The method is quite complex with multiple responsibilities. Consider extracting these into separate methods:

  1. _clear_old_scheduled_releases()
  2. _fetch_item_airtime(item: MediaItem) -> Optional[datetime]
  3. _process_release_schedule(item: MediaItem, air_time: datetime)

Also, the variable items_found_today is assigned but never used.

Remove the unused variable:

-            items_found_today = False  # Track if we found any items for today
             todays_releases = []  # Track items and their release times
🧰 Tools
🪛 Ruff (0.8.2)

154-155: Use a single if statement instead of nested if statements

(SIM102)


165-167: Use a single if statement instead of nested if statements

(SIM102)

🧹 Nitpick comments (5)
src/program/apis/tvmaze_api.py (3)

154-155: Simplify nested conditions.

The nested conditions can be simplified to improve readability.

Apply these changes:

- if hasattr(episode, 'number') and hasattr(episode, 'season'):
-     if episode.season == season_number and episode.number == episode_number:
+ if (hasattr(episode, 'number') and hasattr(episode, 'season') and
+     episode.season == season_number and episode.number == episode_number):

- elif air_time > current_time:
-     if not next_episode or air_time < next_episode:
-         next_episode = air_time
+ elif air_time > current_time and (not next_episode or air_time < next_episode):
+     next_episode = air_time

Also applies to: 165-167

🧰 Tools
🪛 Ruff (0.8.2)

154-155: Use a single if statement instead of nested if statements

(SIM102)


Line range hint 379-397: Add retry mechanism for database operations.

The _update_item_state method handles database operations but doesn't implement any retry mechanism for transient failures.

Consider implementing a retry decorator:

def retry_on_db_error(max_retries=3, delay=1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_retries - 1:
                        raise
                    logger.warning(f"Database operation failed, retrying in {delay}s: {e}")
                    time.sleep(delay)
        return wrapper
    return decorator
🧰 Tools
🪛 Ruff (0.8.2)

154-155: Use a single if statement instead of nested if statements

(SIM102)


165-167: Use a single if statement instead of nested if statements

(SIM102)


Line range hint 401-417: Consider adding health checks for scheduled jobs.

The scheduler implementation looks good, but it might benefit from health monitoring.

Consider:

  1. Adding job execution metrics
  2. Implementing failure notifications
  3. Adding a dead man's switch for critical jobs

Example implementation:

def monitor_job_health(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        try:
            result = func(*args, **kwargs)
            execution_time = time.time() - start_time
            logger.info(f"Job {func.__name__} completed in {execution_time:.2f}s")
            return result
        except Exception as e:
            logger.error(f"Job {func.__name__} failed: {e}")
            # Implement notification mechanism here
            raise
    return wrapper
🧰 Tools
🪛 Ruff (0.8.2)

154-155: Use a single if statement instead of nested if statements

(SIM102)


165-167: Use a single if statement instead of nested if statements

(SIM102)

src/program/program.py (2)

293-294: Simplify nested if statements

The nested if statements can be combined for better readability.

-                                    if item.type == "episode":
-                                        if isinstance(item, Episode):
+                                    if item.type == "episode" and isinstance(item, Episode):
🧰 Tools
🪛 Ruff (0.8.2)

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Remove unused variable

The variable items_found_today is assigned but never used.

-                                    items_found_today = True
🧰 Tools
🪛 Ruff (0.8.2)

350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 01f2b38 and 53f9898.

📒 Files selected for processing (2)
  • src/program/apis/tvmaze_api.py (1 hunks)
  • src/program/program.py (3 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/program.py

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

src/program/apis/tvmaze_api.py

154-155: Use a single if statement instead of nested if statements

(SIM102)


165-167: Use a single if statement instead of nested if statements

(SIM102)

🔇 Additional comments (3)
src/program/program.py (3)

Line range hint 6-67: LGTM: Well-structured initialization of scheduler and time-related components

The addition of time-related imports and scheduler initialization in the constructor provides a solid foundation for the episode release timing feature.


379-397: LGTM: Well-structured item state update handling

The method effectively handles:

  • State updates for individual items
  • Error handling with session rollback
  • Cleanup of scheduled releases

401-418: Verify timezone handling for midnight scheduling

While the code uses astimezone() for current time, ensure that the midnight schedule consistently triggers at the correct local time across different timezones and daylight saving transitions.

✅ Verification successful

Let me check the specific timezone handling in these files to verify if there are any inconsistencies or potential issues.


Timezone handling is properly implemented

The code demonstrates robust timezone handling:

  • Uses astimezone() consistently to work with local time
  • Explicitly handles UTC conversion for Trakt API times using ZoneInfo("UTC")
  • Preserves timezone information when scheduling midnight jobs
  • Other scheduled jobs are interval-based and not affected by timezone changes

The midnight scheduling will work correctly across different timezones and DST transitions because:

  1. The current time is obtained with proper timezone info (datetime.now().astimezone())
  2. The midnight calculation preserves the timezone when using replace()
  3. The scheduler uses 'cron' format which automatically adjusts for timezone changes
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Check for timezone-related code in the codebase

# Look for timezone-related configurations
rg -l "timezone|ZoneInfo|tzinfo" --type py

# Check for other scheduler jobs that might be affected by timezone
ast-grep --pattern 'scheduler.add_job($$$)'

Length of output: 290


Script:

#!/bin/bash
# Check timezone handling in program.py and related files
rg -A 5 -B 5 "timezone|ZoneInfo|tzinfo" src/program/program.py

# Check all scheduler job configurations
rg -A 5 "scheduler.add_job" src/program/program.py

Length of output: 3148

Comment on lines 28 to 184
if not show:
logger.debug(f"Could not find show for {imdb_id} / {show_name}")
return None

# Get next episode
try:
response = self.request_handler._request(HttpMethod.GET, f"{self.BASE_URL}/shows/{show.id}/episodes")
episodes = response.data if response.is_ok else None
except HTTPError as e:
if e.response.status_code == 404:
episodes = None
else:
raise

if not episodes:
return None

# Find the next episode that hasn't aired yet
current_time = datetime.now()
next_episode = None
target_episode_time = None

for episode in episodes:
try:
if not episode.airstamp:
continue

# First try to get air time using network timezone
air_time = None
if (hasattr(show, 'network') and show.network and
hasattr(show.network, 'country') and show.network.country and
hasattr(show.network.country, 'timezone') and show.network.country.timezone and
episode.airdate and episode.airtime):

# Combine airdate and airtime in network timezone
network_tz = ZoneInfo(show.network.country.timezone)
air_datetime = f"{episode.airdate}T{episode.airtime}"
try:
# Parse the time in network timezone
air_time = datetime.fromisoformat(air_datetime).replace(tzinfo=network_tz)
# Only log network time for the target episode
if (season_number is not None and episode_number is not None and
hasattr(episode, 'number') and hasattr(episode, 'season') and
episode.season == season_number and episode.number == episode_number):
logger.debug(f"Network airs show at {air_time} ({show.network.country.timezone})")
except Exception as e:
logger.error(f"Failed to parse network air time: {e}")
air_time = None

# Fallback to airstamp if needed
if not air_time and episode.airstamp:
try:
air_time = datetime.fromisoformat(episode.airstamp.replace('Z', '+00:00'))
if (season_number is not None and episode_number is not None and
hasattr(episode, 'number') and hasattr(episode, 'season') and
episode.season == season_number and episode.number == episode_number):
logger.debug(f"Using UTC airstamp: {air_time}")
except Exception as e:
logger.error(f"Failed to parse airstamp: {e}")
continue

if not air_time:
continue

# Convert to local time
air_time = air_time.astimezone(current_time.tzinfo)

# Check if this is the specific episode we want
if season_number is not None and episode_number is not None:
if hasattr(episode, 'number') and hasattr(episode, 'season'):
if episode.season == season_number and episode.number == episode_number:
# Found our target episode
if hasattr(episode, 'name'):
logger.debug(f"Found S{season_number}E{episode_number} '{episode.name}' airing at {air_time}")
else:
logger.debug(f"Found S{season_number}E{episode_number} airing at {air_time}")
target_episode_time = air_time
break # No need to continue looking

# If we're looking for next episode and this one is in the future
elif air_time > current_time:
# If we haven't found any future episode yet, or this one airs sooner
if not next_episode or air_time < next_episode:
next_episode = air_time

except Exception as e:
logger.error(f"Failed to process episode {getattr(episode, 'number', '?')}: {e}")
continue

# Return target episode time if we found one, otherwise return next episode
if target_episode_time is not None:
return target_episode_time

if next_episode:
logger.debug(f"Next episode airs at {next_episode}")
return next_episode

except Exception as e:
logger.error(f"Error fetching TVMaze data for {imdb_id}: {e}")
return None
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

Consider breaking down the complex method into smaller, focused functions.

The get_show_by_imdb method handles multiple responsibilities including IMDB ID formatting, show lookup via different methods, and episode airtime processing. Consider extracting these into separate methods for better maintainability:

  1. _format_imdb_id(imdb_id: str) -> str
  2. _lookup_show_by_name(show_name: str) -> Optional[Show]
  3. _lookup_show_by_imdb(imdb_id: str) -> Optional[Show]
  4. _get_show_episodes(show_id: int) -> Optional[List[Episode]]

Additionally, consider consolidating the error handling pattern to reduce code duplication:

def _make_api_request(self, endpoint: str, params: dict) -> Optional[Any]:
    try:
        response = self.request_handler._request(HttpMethod.GET, f"{self.BASE_URL}/{endpoint}", params=params)
        return response.data if response.is_ok else None
    except HTTPError as e:
        if e.response.status_code == 404:
            return None
        raise
🧰 Tools
🪛 Ruff (0.8.2)

154-155: Use a single if statement instead of nested if statements

(SIM102)


165-167: Use a single if statement instead of nested if statements

(SIM102)

Comment on lines 220 to 377

tvmaze_api = di[TVMazeAPI]
# Get show title - for episodes, use the parent show's title
show_name = item.get_top_title()
if not show_name:
continue

# Skip if it's just an episode number
if show_name.lower().startswith("episode "):
continue

# For episodes, pass the season and episode numbers
season_number = None
episode_number = None
if item.type == "episode":
if isinstance(item, Episode):
season_number = item.parent.number if item.parent else None
episode_number = item.number

tvmaze_time = tvmaze_api.get_show_by_imdb(
item.imdb_id,
show_name=show_name,
season_number=season_number,
episode_number=episode_number
)
if tvmaze_time:
logger.debug(f"TVMaze airtime for {item.log_string}: {tvmaze_time}")
except Exception as e:
logger.error(f"Failed to fetch airtime from TVMaze for {item.log_string}: {e}")

# Use the earliest available time
if trakt_time and tvmaze_time:
air_time = min(trakt_time, tvmaze_time)
logger.debug(f"Using earliest time between Trakt ({trakt_time}) and TVMaze ({tvmaze_time})")
else:
air_time = trakt_time or tvmaze_time
if not air_time:
logger.debug(f"No airtime available from either Trakt or TVMaze for {item.log_string}")
continue # Skip this item

if not air_time: # Add explicit check
logger.debug(f"No valid air time found for {item.log_string}")
continue

# Store the local time in the database
item.aired_at = air_time
session.merge(item)
logger.debug(f"Updated airtime for {item.log_string} to {air_time}")

# Calculate delayed release time
delay_minutes = settings_manager.settings.content.trakt.release_delay_minutes
delayed_time = air_time + timedelta(minutes=delay_minutes)

# Format times for display (e.g., "8:00 PM")
air_time_str = air_time.strftime("%I:%M %p").lstrip('0')
release_time_str = delayed_time.strftime("%I:%M %p").lstrip('0')

# If it aired in the past (including delay), release it immediately
if delayed_time <= current_time:
logger.log("PROGRAM", f"- {item.log_string} was scheduled to release at {air_time_str}")
# Trigger immediate state update
previous_state, new_state = item.store_state()
if previous_state != new_state:
self.em.add_event(Event(emitted_by="UpdateOngoing", item_id=item.id))
logger.debug(f"Updated state for {item.log_string} ({item.id}) from {previous_state.name} to {new_state.name}")
continue

# For future items, only schedule if they air today
if (air_time.year == current_time.year and
air_time.month == current_time.month and
air_time.day == current_time.day):
items_found_today = True
todays_releases.append((item.log_string, release_time_str))
logger.debug(f"Found today's item: {item.log_string}")
logger.log("PROGRAM", f"- {item.log_string} will release at {release_time_str} (included {delay_minutes}min delay)")
# Add to scheduled releases if not already there
if item.id not in self.scheduled_releases:
self.scheduled_releases[item.id] = delayed_time
except Exception as e:
logger.error(f"Failed to fetch airtime for {item.log_string}: {e}")
continue

# Commit any airtime updates
session.commit()

# Log summary of today's releases
if todays_releases:
logger.log("PROGRAM", "\nToday's releases:")
for item_name, release_time in sorted(todays_releases, key=lambda x: x[1]):
logger.log("PROGRAM", f" • {item_name} at {release_time}")
else:
logger.log("PROGRAM", "\nNo releases scheduled for today")

except Exception as e:
logger.error(f"Failed to update state for item with ID {item_id}: {e}")
session.rollback()
logger.error(f"Database error in _update_ongoing: {e}")
raise
except Exception as e:
logger.error(f"Error in _update_ongoing: {e}")
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

Consider breaking down the _update_ongoing method into smaller functions

The method is handling multiple responsibilities including:

  • Clearing old scheduled releases
  • Fetching air times from multiple APIs
  • Updating database records
  • Scheduling future releases

Consider extracting these into separate methods for better maintainability and testability.

Here's a suggested structure:

def _update_ongoing(self) -> None:
    try:
        current_time = self._get_current_time()
        self._clear_old_scheduled_releases(current_time)
        self._process_unreleased_items(current_time)
    except Exception as e:
        logger.error(f"Error in _update_ongoing: {e}")

def _clear_old_scheduled_releases(self, current_time: datetime) -> None:
    # Lines 229-233

def _process_unreleased_items(self, current_time: datetime) -> None:
    # Lines 238-371

def _fetch_air_times(self, item: MediaItem) -> tuple[datetime, datetime]:
    # Extract air time fetching logic
🧰 Tools
🪛 Ruff (0.8.2)

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/program/program.py (1)

220-376: 🛠️ Refactor suggestion

Refactor _update_ongoing method into smaller functions

The _update_ongoing method currently handles multiple responsibilities:

  • Clearing old scheduled releases
  • Fetching airtimes from Trakt and TVMaze APIs
  • Updating database records
  • Scheduling and logging future releases
  • Error handling

Splitting this method into smaller, cohesive functions enhances maintainability and testability.

Consider refactoring as follows:

def _update_ongoing(self) -> None:
    try:
        current_time = self._get_current_time()
        self._clear_old_scheduled_releases(current_time)
        items = self._fetch_items_to_update()
        self._process_items(items, current_time)
    except Exception as e:
        logger.error(f"Error in _update_ongoing: {e}")

def _get_current_time(self) -> datetime:
    return datetime.now().astimezone()

def _clear_old_scheduled_releases(self, current_time: datetime) -> None:
    # Logic from lines 229-233

def _fetch_items_to_update(self):
    # Logic from lines 239-245
    return items

def _process_items(self, items, current_time: datetime) -> None:
    # Logic from lines 247-370
🧰 Tools
🪛 Ruff (0.8.2)

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

🧹 Nitpick comments (2)
src/program/program.py (2)

293-297: Simplify nested if statements when assigning season and episode numbers

Combining the nested if statements into a single condition improves readability and reduces code complexity.

Apply this diff to streamline the conditions:

-season_number = None
-episode_number = None
-if item.type == "episode":
-    if isinstance(item, Episode):
-        season_number = item.parent.number if item.parent else None
-        episode_number = item.number
+if item.type == "episode" and isinstance(item, Episode):
+    season_number = item.parent.number if item.parent else None
+    episode_number = item.number
+else:
+    season_number = None
+    episode_number = None
🧰 Tools
🪛 Ruff (0.8.2)

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Remove unused variable items_found_today

The variable items_found_today is assigned but not used elsewhere in the _update_ongoing method. Removing it eliminates unnecessary code.

Apply this diff to remove the unused variable:

-    items_found_today = True  # Track if we found any items for today
🧰 Tools
🪛 Ruff (0.8.2)

350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 53f9898 and 6d690cc.

📒 Files selected for processing (2)
  • src/program/program.py (3 hunks)
  • src/routers/secure/items.py (2 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/program.py

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

Comment on lines +231 to +246

# Get all matching items and use the first one
items = session.execute(query).unique().scalars().all()
if not items:
raise HTTPException(status_code=404, detail="Item not found")

# Use the first item if there are multiple
item = items[0]
if len(items) > 1:
# Log details about each duplicate
logger.warning(f"Multiple items found for ID {id}:")
for i, dupe in enumerate(items):
logger.warning(f" {i+1}. {dupe.type} - {dupe.log_string} (ID: {dupe.id})")
logger.warning(f"Using first item: {item.type} - {item.log_string}")

return item.to_extended_dict(with_streams=False)
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

Handle duplicate items more robustly in get_item

Returning the first item when multiple items are found for a given ID may lead to inconsistent behavior. This could mask underlying data integrity issues.

Consider updating the logic to:

  • Investigate why duplicates exist and address the root cause in the database.
  • Modify the function to raise a HTTPException indicating multiple items found, prompting to specify a more precise query or to resolve duplicates.

Alternatively, if duplicates are acceptable in this context, clearly document the behavior and ensure it aligns with the intended application logic.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/program/program.py (1)

293-294: Address static analysis warnings

  1. Simplify nested if statements:
-    if item.type == "episode":
-        if isinstance(item, Episode):
+    if item.type == "episode" and isinstance(item, Episode):
  1. Remove unused variable:
-    items_found_today = False  # Track if we found any items for today

Also applies to: 350-350

🧰 Tools
🪛 Ruff (0.8.2)

293-294: Use a single if statement instead of nested if statements

(SIM102)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6d690cc and e9c0103.

📒 Files selected for processing (1)
  • src/program/program.py (3 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/program.py

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

🔇 Additional comments (3)
src/program/program.py (3)

432-452: LGTM! Well-structured release processing

The method has good error handling, proper cleanup of scheduled releases, and appropriate logging of release events.


456-473: LGTM! Well-implemented scheduling logic

The scheduling implementation includes:

  • Appropriate midnight scheduling for updates
  • Good misfire grace period handling
  • Immediate execution on startup

220-377: 🛠️ Refactor suggestion

Method is too complex and needs refactoring

The _update_ongoing method is handling multiple responsibilities including:

  • Clearing old scheduled releases
  • Fetching air times from multiple APIs
  • Updating database records
  • Scheduling future releases

Consider breaking it down into smaller, focused methods for better maintainability and testability.

 def _update_ongoing(self) -> None:
+    try:
+        current_time = datetime.now().astimezone()
+        self._clear_old_scheduled_releases(current_time)
+        self._process_unreleased_items(current_time)
+    except Exception as e:
+        logger.error(f"Error in _update_ongoing: {e}")
+
+def _clear_old_scheduled_releases(self, current_time: datetime) -> None:
+    new_scheduled_releases = {}
+    for k, v in self.scheduled_releases.items():
+        if v > current_time:
+            new_scheduled_releases[k] = v
+    self.scheduled_releases = new_scheduled_releases
+
+def _process_unreleased_items(self, current_time: datetime) -> None:
     # Rest of the current _update_ongoing logic

Likely invalid or redundant comment.

🧰 Tools
🪛 Ruff (0.8.2)

293-294: Use a single if statement instead of nested if statements

(SIM102)


350-350: Local variable items_found_today is assigned to but never used

Remove assignment to unused variable items_found_today

(F841)

Comment on lines 65 to 67
self.scheduled_releases = {} # Track scheduled releases
self.scheduler = BackgroundScheduler() # Initialize the scheduler
self.scheduler.start() # Start the scheduler
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

Consider moving scheduler start to a more appropriate location

Starting the scheduler in __init__ could be problematic if object creation fails. Consider moving the scheduler start to the start() method where other initializations occur, and add proper error handling.

 def __init__(self):
     super().__init__(name="Riven")
     self.initialized = False
     self.running = False
     self.services = {}
     self.enable_trace = settings_manager.settings.tracemalloc
     self.em = EventManager()
     self.scheduled_releases = {}  # Track scheduled releases
-    self.scheduler = BackgroundScheduler()  # Initialize the scheduler
-    self.scheduler.start()  # Start the scheduler
+    self.scheduler = BackgroundScheduler()

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +429 to +430
if item_id in self.scheduled_releases and self.scheduled_releases[item_id] <= current_time:
del self.scheduled_releases[item_id]
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Codebase verification

Based on the analysis of the codebase, I can now provide the final response:

Race condition concern is valid - synchronization needed

The code shows that Program class inherits from threading.Thread and operates in a multi-threaded environment. The scheduled_releases dictionary is accessed from multiple contexts:

  • Main thread in _update_item_state
  • Background scheduler thread in _process_release
  • Dictionary iteration and modification in multiple places

Implement the suggested lock-based synchronization to prevent potential race conditions.

🔗 Analysis chain

Potential race condition in scheduled releases cleanup

The check and removal of scheduled releases could lead to a race condition in a multi-threaded environment. Consider using a thread-safe operation or lock.

+    from threading import Lock
+
 class Program(threading.Thread):
     def __init__(self):
         # ... existing init code ...
+        self._scheduled_releases_lock = Lock()
+
+    def _remove_scheduled_release(self, item_id: str, current_time: datetime) -> None:
+        with self._scheduled_releases_lock:
+            if item_id in self.scheduled_releases and self.scheduled_releases[item_id] <= current_time:
+                del self.scheduled_releases[item_id]

Let me analyze the concurrent access patterns to scheduled_releases further.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check for concurrent access to scheduled_releases
rg -A 5 "self\.scheduled_releases" | grep -v "# Track scheduled releases"

Length of output: 2996


Script:

#!/bin/bash
# Check if Program class inherits from Thread and look for any threading-related code
ast-grep --pattern 'class Program(threading.Thread)'

# Check for any other thread creation or async patterns
rg "Thread|threading|asyncio|concurrent" -A 3

Length of output: 16267

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/program/program.py (3)

250-250: Fix unused loop variable

The loop variable aired_at is not used within the loop body.

-    for item, aired_at in items:
+    for item, _ in items:
🧰 Tools
🪛 Ruff (0.8.2)

250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)


444-468: Consider using explicit transaction management

The method could benefit from explicit transaction management to ensure atomicity of the release operation.

 def _process_release(self, item_id: str, log_string: str) -> None:
     """Process a scheduled release at its designated time."""
     try:
         with db.Session() as session:
+            with session.begin():  # Explicit transaction
                 item = session.execute(
                     select(MediaItem).where(MediaItem.id == item_id)
                 ).unique().scalar_one_or_none()
                 
                 if item:
                     if item.last_state in [States.Ongoing, States.Unreleased]:
                         previous_state, new_state = item.store_state()
                         if previous_state != new_state:
                             self.em.add_event(Event("StateTransition", item_id=item_id))
                             release_time = datetime.now().astimezone().strftime("%I:%M %p").lstrip('0')
                             logger.log("RELEASE", f"🎬 Released at {release_time}: {log_string}")
                             session.merge(item)
-                            session.commit()
                 else:
                     logger.error(f"Item {item_id} not found during scheduled release")
                 
                 # Clean up the scheduled release
+                with self._scheduled_releases_lock:
                     self.scheduled_releases.pop(item_id, None)

474-474: Remove unused variable

The midnight variable is calculated but never used.

-    midnight = (current_time + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
🧰 Tools
🪛 Ruff (0.8.2)

474-474: Local variable midnight is assigned to but never used

Remove assignment to unused variable midnight

(F841)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e9c0103 and 31ed143.

📒 Files selected for processing (1)
  • src/program/program.py (6 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/program.py

250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)


474-474: Local variable midnight is assigned to but never used

Remove assignment to unused variable midnight

(F841)

🔇 Additional comments (4)
src/program/program.py (4)

605-611: LGTM: Clean shutdown implementation

The stop method properly handles the scheduler shutdown and program termination.


236-236: ⚠️ Potential issue

Add thread safety for scheduled releases

The scheduled_releases dictionary is accessed from multiple threads (scheduler and main thread) without synchronization.

 def __init__(self):
     # ... existing init code ...
     self.scheduled_releases = {}  # Track scheduled releases
+    self._scheduled_releases_lock = threading.Lock()

 def _update_ongoing(self) -> None:
     try:
         current_time = datetime.now().astimezone()
         logger.log("PROGRAM", "Checking for upcoming releases...")
         trakt_api = di[TraktAPI]

         # Clear old scheduled releases
+        with self._scheduled_releases_lock:
             self.scheduled_releases = {k: v for k, v in self.scheduled_releases.items() 
                                     if v > current_time}

Likely invalid or redundant comment.


66-76: 🛠️ Refactor suggestion

Consider moving scheduler initialization to start() method

Starting the scheduler in __init__ could be problematic if object creation fails. Consider moving the scheduler initialization and start to the start() method where other initializations occur.

 def __init__(self):
     super().__init__(name="Riven")
     self.initialized = False
     self.running = False
     self.services = {}
     self.enable_trace = settings_manager.settings.tracemalloc
     self.em = EventManager()
     self.scheduled_releases = {}  # Track scheduled releases
-    self.scheduler = BackgroundScheduler(
-        timezone=datetime.now().astimezone().tzinfo,  # Use system timezone
-    )
-    logging.getLogger('apscheduler').setLevel(logging.WARNING)
-    self.scheduler.start()  # Start the scheduler

 def start(self):
+    # Configure and start scheduler
+    self.scheduler = BackgroundScheduler(
+        timezone=datetime.now().astimezone().tzinfo,  # Use system timezone
+    )
+    logging.getLogger('apscheduler').setLevel(logging.WARNING)
+    try:
+        self.scheduler.start()
+    except Exception as e:
+        logger.error(f"Failed to start scheduler: {e}")
+        return

Likely invalid or redundant comment.


441-442: ⚠️ Potential issue

Add thread safety and extract common scheduling logic

  1. The scheduled releases cleanup needs thread safety
  2. The scheduling logic is duplicated between _update_item_state and _update_ongoing
+def _schedule_release(self, item: MediaItem, release_time: datetime) -> None:
+    """Schedule a release for the given item."""
+    with self._scheduled_releases_lock:
+        self.scheduled_releases[item.id] = release_time
+    
+    job_id = f"release_{item.id}"
+    try:
+        if self.scheduler.get_job(job_id):
+            self.scheduler.remove_job(job_id)
+        
+        self.scheduler.add_job(
+            self._process_release,
+            'date',
+            run_date=release_time,
+            args=[item.id, item.log_string],
+            id=job_id,
+            name=f"Release {item.log_string}"
+        )
+    except Exception as e:
+        logger.error(f"Failed to schedule release for {item.log_string}: {e}")

 def _update_item_state(self, item_id: str) -> None:
     # ... existing code ...
     finally:
         # Remove from scheduled releases after processing
+        with self._scheduled_releases_lock:
             if item_id in self.scheduled_releases and self.scheduled_releases[item_id] <= current_time:
                 del self.scheduled_releases[item_id]

Likely invalid or redundant comment.

Comment on lines 230 to 373
item.network = trakt_item.network
except Exception as e:
logger.error(f"Failed to fetch Trakt time for {item.log_string}: {e}")

# Get TVMaze time for episodes only
if item.type == "episode":
try:
tvmaze_api = di[TVMazeAPI]
show_name = item.get_top_title()
if show_name and not show_name.lower().startswith("episode "):
season_number = item.parent.number if isinstance(item, Episode) and item.parent else None
episode_number = item.number if isinstance(item, Episode) else None

tvmaze_time = tvmaze_api.get_show_by_imdb(
item.imdb_id,
show_name=show_name,
season_number=season_number,
episode_number=episode_number
)
except Exception as e:
logger.error(f"Failed to fetch TVMaze time for {item.log_string}: {e}")

# Use earliest available time
times = [t for t in [trakt_time, tvmaze_time] if t is not None]
if not times:
continue
air_time = min(times)
if not air_time:
continue

# Store the air time
item.aired_at = air_time
session.merge(item)

# Calculate release time with delay
delay_minutes = settings_manager.settings.content.trakt.release_delay_minutes
release_time = air_time + timedelta(minutes=delay_minutes)

# Skip if already released
if release_time <= current_time:
if item.last_state in [States.Ongoing, States.Unreleased]:
previous_state, new_state = item.store_state()
if previous_state != new_state:
self.em.add_event(Event("StateTransition", item_id=item.id))
logger.log("RELEASE", f"🎬 Released (late): {item.log_string}")
continue

# Check if releasing in next 24 hours
time_until_release = release_time - current_time
if time_until_release <= timedelta(hours=24):
release_time_str = release_time.strftime("%I:%M %p").lstrip('0')
upcoming_releases.append((item.log_string, release_time_str))

# Schedule the release
self.scheduled_releases[item.id] = release_time
job_id = f"release_{item.id}"

try:
if self.scheduler.get_job(job_id):
self.scheduler.remove_job(job_id)

self.scheduler.add_job(
self._process_release,
'date',
run_date=release_time,
args=[item.id, item.log_string],
id=job_id,
name=f"Release {item.log_string}"
)
logger.log("PROGRAM", f"📅 Scheduled: {item.log_string} at {release_time_str}")
except Exception as e:
logger.error(f"Failed to schedule release for {item.log_string}: {e}")

# If this episode isn't releasing soon, skip rest of the season
if item.type == "episode":
checked_shows.add(show_id)

except Exception as e:
logger.error(f"Failed to process {item.log_string}: {e}")
continue

session.commit()

# Log summary of scheduled releases
if upcoming_releases:
logger.log("PROGRAM", "\n📅 Scheduled releases:")
for item_name, release_time in sorted(upcoming_releases, key=lambda x: x[1]):
logger.log("PROGRAM", f" • {item_name} at {release_time}")
else:
logger.log("PROGRAM", "No upcoming releases found")

except Exception as e:
logger.error(f"Failed to update state for item with ID {item_id}: {e}")
session.rollback()
logger.error(f"Database error in _update_ongoing: {e}")
raise

except Exception as e:
logger.error(f"Error in _update_ongoing: {e}")

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

Method is too complex and needs refactoring

The _update_ongoing method is handling multiple responsibilities including API calls, database operations, and scheduling. Consider breaking it down into smaller, focused methods:

def _update_ongoing(self) -> None:
    try:
        current_time = datetime.now().astimezone()
        logger.log("PROGRAM", "Checking for upcoming releases...")
        self._clear_old_scheduled_releases(current_time)
        upcoming_releases = self._process_unreleased_items(current_time)
        self._log_upcoming_releases(upcoming_releases)
    except Exception as e:
        logger.error(f"Error in _update_ongoing: {e}")

def _clear_old_scheduled_releases(self, current_time: datetime) -> None:
    with self._scheduled_releases_lock:
        self.scheduled_releases = {k: v for k, v in self.scheduled_releases.items() 
                                 if v > current_time}

def _process_unreleased_items(self, current_time: datetime) -> list[tuple[str, str]]:
    # Extract the main processing logic here
    
def _log_upcoming_releases(self, upcoming_releases: list[tuple[str, str]]) -> None:
    # Extract the logging logic here
🧰 Tools
🪛 Ruff (0.8.2)

250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/program/program.py (1)

441-442: ⚠️ Potential issue

Race condition in scheduled releases access

The access to scheduled_releases dictionary is not thread-safe. This could lead to race conditions in a multi-threaded environment.

+from threading import Lock
+
 class Program(threading.Thread):
     def __init__(self):
         # ... existing init code ...
+        self._scheduled_releases_lock = Lock()
+
     def _update_item_state(self, item_id: str) -> None:
         # ... existing code ...
-        if item_id in self.scheduled_releases and self.scheduled_releases[item_id] <= current_time:
-            del self.scheduled_releases[item_id]
+        with self._scheduled_releases_lock:
+            if item_id in self.scheduled_releases and self.scheduled_releases[item_id] <= current_time:
+                del self.scheduled_releases[item_id]
🧹 Nitpick comments (2)
src/program/program.py (2)

250-250: Fix unused variable in loop

The loop control variable aired_at is not used within the loop body.

-    for item, aired_at in items:
+    for item, _aired_at in items:
🧰 Tools
🪛 Ruff (0.8.2)

250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)


474-474: Remove unused variable

The midnight variable is assigned but never used.

-    midnight = (current_time + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
🧰 Tools
🪛 Ruff (0.8.2)

474-474: Local variable midnight is assigned to but never used

Remove assignment to unused variable midnight

(F841)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 31ed143 and 7d3a004.

📒 Files selected for processing (1)
  • src/program/program.py (6 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/program.py

250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)


474-474: Local variable midnight is assigned to but never used

Remove assignment to unused variable midnight

(F841)

🔇 Additional comments (4)
src/program/program.py (4)

444-468: LGTM! Well-structured release processing

The method has proper error handling, database session management, and logging.


605-611: LGTM! Proper cleanup implementation

The method properly handles scheduler shutdown and logging.


66-76: 🛠️ Refactor suggestion

Move scheduler start to a more appropriate location

Starting the scheduler in __init__ could be problematic if object creation fails. Consider moving the scheduler start to the start() method where other initializations occur.

 def __init__(self):
     super().__init__(name="Riven")
     self.initialized = False
     self.running = False
     self.services = {}
     self.enable_trace = settings_manager.settings.tracemalloc
     self.em = EventManager()
     self.scheduled_releases = {}  # Track scheduled releases
     
     # Configure scheduler with timezone awareness
     self.scheduler = BackgroundScheduler(
         timezone=datetime.now().astimezone().tzinfo,  # Use system timezone
     )
     
     # Disable noisy debug logs from APScheduler
     logging.getLogger('apscheduler').setLevel(logging.WARNING)
-    
-    self.scheduler.start()  # Start the scheduler

Likely invalid or redundant comment.


230-373: 🛠️ Refactor suggestion

Break down the _update_ongoing method into smaller functions

The method is handling multiple responsibilities including:

  • Clearing old scheduled releases
  • Fetching air times from multiple APIs
  • Updating database records
  • Scheduling future releases

This makes it hard to maintain and test.

 def _update_ongoing(self) -> None:
+    def _fetch_air_times(self, item: MediaItem) -> tuple[datetime | None, datetime | None]:
+        trakt_time = None
+        tvmaze_time = None
+        try:
+            trakt_time = self._fetch_trakt_time(item)
+        except Exception as e:
+            logger.error(f"Failed to fetch Trakt time for {item.log_string}: {e}")
+        
+        if item.type == "episode":
+            try:
+                tvmaze_time = self._fetch_tvmaze_time(item)
+            except Exception as e:
+                logger.error(f"Failed to fetch TVMaze time for {item.log_string}: {e}")
+        
+        return trakt_time, tvmaze_time
+
     try:
         current_time = datetime.now().astimezone()
         logger.log("PROGRAM", "Checking for upcoming releases...")
         # ... rest of the method

Likely invalid or redundant comment.

🧰 Tools
🪛 Ruff (0.8.2)

250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/program/apis/tvmaze_api.py (1)

154-156: Simplify nested if statements.

-                    if season_number is not None and episode_number is not None:
-                        if hasattr(episode, 'number') and hasattr(episode, 'season'):
-                            if episode.season == season_number and episode.number == episode_number:
+                    if (season_number is not None and episode_number is not None and
+                        hasattr(episode, 'number') and hasattr(episode, 'season') and
+                        episode.season == season_number and episode.number == episode_number):
🧰 Tools
🪛 Ruff (0.8.2)

154-156: Use a single if statement instead of nested if statements

(SIM102)


155-156: Use a single if statement instead of nested if statements

(SIM102)

src/program/program.py (1)

438-462: Consider adding error state handling.

The method should handle cases where the release process fails by potentially setting an error state or scheduling a retry.

     def _process_release(self, item_id: str, log_string: str) -> None:
         try:
             with db.Session() as session:
                 item = session.execute(
                     select(MediaItem).where(MediaItem.id == item_id)
                 ).unique().scalar_one_or_none()
                 
                 if item:
                     if item.last_state in [States.Ongoing, States.Unreleased]:
                         previous_state, new_state = item.store_state()
                         if previous_state != new_state:
                             self.em.add_event(Event("StateTransition", item_id=item_id))
                             release_time = datetime.now().astimezone().strftime("%I:%M %p").lstrip('0')
                             logger.log("RELEASE", f"🎬 Released at {release_time}: {log_string}")
                             session.merge(item)
                             session.commit()
+                        else:
+                            logger.warning(f"State transition failed for {log_string}")
+                            # Consider setting an error state
+                            item.last_state = States.Error
+                            session.merge(item)
+                            session.commit()
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d3a004 and d3bd27a.

📒 Files selected for processing (2)
  • src/program/apis/tvmaze_api.py (1 hunks)
  • src/program/program.py (6 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/program.py

239-239: Local variable checked_shows is assigned to but never used

Remove assignment to unused variable checked_shows

(F841)


250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)


468-468: Local variable midnight is assigned to but never used

Remove assignment to unused variable midnight

(F841)

src/program/apis/tvmaze_api.py

154-156: Use a single if statement instead of nested if statements

(SIM102)


155-156: Use a single if statement instead of nested if statements

(SIM102)

🔇 Additional comments (7)
src/program/apis/tvmaze_api.py (4)

1-27: LGTM! Well-configured API client setup.

The TVMaze API client is properly configured with:

  • Appropriate rate limiting (20 requests/10s)
  • 24-hour caching
  • Proper response type handling

28-39: Method documentation is clear and complete.

The docstring properly documents the method's parameters and return value.


89-146: Timezone handling is robust.

The code properly handles:

  • Network timezone conversion
  • UTC fallback
  • Local timezone conversion

40-88: 🛠️ Refactor suggestion

Consider extracting show lookup logic into separate methods.

The show lookup logic has multiple strategies that could be extracted into separate methods for better maintainability:

+    def _lookup_show_by_name(self, show_name: str) -> Optional[Any]:
+        """Try to find show using singlesearch by name."""
+        try:
+            response = self.request_handler._request(
+                HttpMethod.GET,
+                f"{self.BASE_URL}/singlesearch/shows",
+                params={'q': show_name}
+            )
+            return response.data if response.is_ok else None
+        except HTTPError as e:
+            if e.response.status_code == 404:
+                return None
+            raise
+
+    def _lookup_show_by_imdb(self, imdb_id: str) -> Optional[Any]:
+        """Try to find show using direct IMDB lookup."""
+        try:
+            response = self.request_handler._request(
+                HttpMethod.GET,
+                f"{self.BASE_URL}/lookup/shows",
+                params={'imdb': imdb_id}
+            )
+            return response.data if response.is_ok else None
+        except HTTPError as e:
+            if e.response.status_code == 404:
+                return None
+            raise

Likely invalid or redundant comment.

src/program/program.py (3)

66-76: LGTM! Well-configured scheduler setup.

The scheduler configuration includes:

  • Proper timezone awareness using system timezone
  • Appropriate log level configuration
  • Immediate scheduler start

599-604: LGTM! Proper scheduler shutdown.

The scheduler is correctly shutdown when stopping the program.


368-437: ⚠️ Potential issue

Add thread safety for scheduled releases access.

The scheduled_releases dictionary is accessed from multiple threads without proper synchronization. This could lead to race conditions.

+from threading import Lock
+
 class Program(threading.Thread):
     def __init__(self):
         super().__init__(name="Riven")
         self.initialized = False
         self.running = False
         self.services = {}
         self.enable_trace = settings_manager.settings.tracemalloc
         self.em = EventManager()
         self.scheduled_releases = {}
+        self._scheduled_releases_lock = Lock()

     def _update_item_state(self, item_id: str) -> None:
         # ... existing code ...
-        if item_id in self.scheduled_releases and self.scheduled_releases[item_id] <= current_time:
-            del self.scheduled_releases[item_id]
+        with self._scheduled_releases_lock:
+            if item_id in self.scheduled_releases and self.scheduled_releases[item_id] <= current_time:
+                del self.scheduled_releases[item_id]

Likely invalid or redundant comment.

Comment on lines +230 to +367
item.network = trakt_item.network
except Exception as e:
logger.error(f"Failed to fetch Trakt time for {item.log_string}: {e}")

# Get TVMaze time for episodes only
if item.type == "episode":
try:
tvmaze_api = di[TVMazeAPI]
show_name = item.get_top_title()
if show_name and not show_name.lower().startswith("episode "):
season_number = item.parent.number if isinstance(item, Episode) and item.parent else None
episode_number = item.number if isinstance(item, Episode) else None

tvmaze_time = tvmaze_api.get_show_by_imdb(
item.imdb_id,
show_name=show_name,
season_number=season_number,
episode_number=episode_number
)
except Exception as e:
logger.error(f"Failed to fetch TVMaze time for {item.log_string}: {e}")

# Use earliest available time
times = [t for t in [trakt_time, tvmaze_time] if t is not None]
if not times:
continue
air_time = min(times)
if not air_time:
continue

# Store the air time
item.aired_at = air_time
session.merge(item)

# Calculate release time with delay
delay_minutes = settings_manager.settings.content.trakt.release_delay_minutes
release_time = air_time + timedelta(minutes=delay_minutes)

# Skip if already released
if release_time <= current_time:
if item.last_state in [States.Ongoing, States.Unreleased]:
previous_state, new_state = item.store_state()
if previous_state != new_state:
self.em.add_event(Event("StateTransition", item_id=item.id))
logger.log("RELEASE", f"🎬 Released (late): {item.log_string}")
continue

# Check if releasing in next 24 hours
time_until_release = release_time - current_time
if time_until_release <= timedelta(hours=24):
release_time_str = release_time.strftime("%I:%M %p").lstrip('0')
upcoming_releases.append((item.log_string, release_time_str))

# Schedule the release
self.scheduled_releases[item.id] = release_time
job_id = f"release_{item.id}"

try:
if self.scheduler.get_job(job_id):
self.scheduler.remove_job(job_id)

self.scheduler.add_job(
self._process_release,
'date',
run_date=release_time,
args=[item.id, item.log_string],
id=job_id,
name=f"Release {item.log_string}"
)
logger.log("PROGRAM", f"📅 Scheduled: {item.log_string} at {release_time_str}")
except Exception as e:
logger.error(f"Failed to schedule release for {item.log_string}: {e}")

# If this episode isn't releasing soon, skip rest of the season
# if item.type == "episode":
# checked_shows.add(show_id)

except Exception as e:
logger.error(f"Failed to process {item.log_string}: {e}")
continue

session.commit()

# Log summary of scheduled releases
if upcoming_releases:
logger.log("PROGRAM", "\n📅 Scheduled releases:")
for item_name, release_time in sorted(upcoming_releases, key=lambda x: x[1]):
logger.log("PROGRAM", f" • {item_name} at {release_time}")
else:
logger.log("PROGRAM", "No upcoming releases found")

except Exception as e:
logger.error(f"Failed to update state for item with ID {item_id}: {e}")
session.rollback()
logger.error(f"Database error in _update_ongoing: {e}")
raise

except Exception as e:
logger.error(f"Error in _update_ongoing: {e}")

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

Method is too complex and needs refactoring.

Consider breaking down this method into smaller, focused methods:

def _update_ongoing(self) -> None:
    """Update state for ongoing and unreleased items."""
    try:
        current_time = datetime.now().astimezone()
        logger.log("PROGRAM", "Checking for upcoming releases...")
        self._clear_old_scheduled_releases(current_time)
        upcoming_releases = self._process_unreleased_items(current_time)
        self._log_upcoming_releases(upcoming_releases)
    except Exception as e:
        logger.error(f"Error in _update_ongoing: {e}")

def _clear_old_scheduled_releases(self, current_time: datetime) -> None:
    self.scheduled_releases = {k: v for k, v in self.scheduled_releases.items() 
                             if v > current_time}

def _process_unreleased_items(self, current_time: datetime) -> list[tuple[str, str]]:
    upcoming_releases = []
    with db.Session() as session:
        items = self._fetch_unreleased_items(session)
        for item in items:
            release_info = self._process_single_item(item, current_time, session)
            if release_info:
                upcoming_releases.append(release_info)
    return upcoming_releases

Also, remove unused variables:

-            checked_shows = set()  # Unused
-            for item, aired_at in items:  # aired_at unused
+            for item, _ in items:
🧰 Tools
🪛 Ruff (0.8.2)

239-239: Local variable checked_shows is assigned to but never used

Remove assignment to unused variable checked_shows

(F841)


250-250: Loop control variable aired_at not used within loop body

Rename unused aired_at to _aired_at

(B007)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/program/apis/tvmaze_api.py (4)

1-27: LGTM! Consider adding API version documentation.

The imports, rate limiting, and caching setup are well-structured. The rate limit of 20 requests per 10 seconds aligns with TVMaze's API limits.

Consider adding a comment or docstring mentioning which TVMaze API version this client is compatible with.


28-44: Add input validation for IMDB ID format.

While the code handles the 'tt' prefix, it should validate the IMDB ID format to ensure it contains only alphanumeric characters and matches the expected pattern (e.g., 'tt' followed by 7-8 digits).

 def get_show_by_imdb(self, imdb_id: str, show_name: Optional[str] = None, season_number: Optional[int] = None, episode_number: Optional[int] = None) -> Optional[datetime]:
+    if not imdb_id or not isinstance(imdb_id, str):
+        logger.error("Invalid IMDB ID: must be a non-empty string")
+        return None
+
     # Add 'tt' prefix if not present
     if not imdb_id.startswith('tt'):
         imdb_id = f'tt{imdb_id}'
+
+    # Validate IMDB ID format
+    if not imdb_id[2:].isalnum() or not len(imdb_id) in [9, 10]:
+        logger.error(f"Invalid IMDB ID format: {imdb_id}")
+        return None

45-88: Refactor duplicated error handling logic.

The error handling pattern for HTTP 404 is repeated three times. Consider extracting it into a helper method.

+    def _make_api_request(self, endpoint: str, params: dict) -> Optional[Any]:
+        """Make an API request with consistent error handling."""
+        try:
+            response = self.request_handler._request(HttpMethod.GET, f"{self.BASE_URL}/{endpoint}", params=params)
+            return response.data if response.is_ok else None
+        except HTTPError as e:
+            if e.response.status_code == 404:
+                return None
+            raise

     def get_show_by_imdb(self, imdb_id: str, show_name: Optional[str] = None, season_number: Optional[int] = None, episode_number: Optional[int] = None) -> Optional[datetime]:
         # ... existing code ...
         
         # Try singlesearch by show name first
         if show_name:
             logger.debug(f"Trying singlesearch by name: {show_name}")
-            try:
-                response = self.request_handler._request(HttpMethod.GET, f"{self.BASE_URL}/singlesearch/shows", params={'q': show_name})
-                show = response.data if response.is_ok else None
-            except HTTPError as e:
-                if e.response.status_code == 404:
-                    show = None
-                else:
-                    raise
+            show = self._make_api_request("singlesearch/shows", {'q': show_name})

195-198: Consider catching specific exception types.

The broad Exception catch could mask unexpected errors. Consider catching specific exceptions that you expect to handle.

-        except Exception as e:
+        except (ValueError, KeyError, AttributeError, TypeError) as e:
             logger.error(f"Error fetching TVMaze data for {imdb_id}: {e}")
             return None
+        except Exception as e:
+            logger.error(f"Unexpected error fetching TVMaze data for {imdb_id}: {e}")
+            raise
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d3bd27a and 604f528.

📒 Files selected for processing (1)
  • src/program/apis/tvmaze_api.py (1 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
src/program/apis/tvmaze_api.py

154-156: Use a single if statement instead of nested if statements

(SIM102)

🔇 Additional comments (2)
src/program/apis/tvmaze_api.py (2)

154-162: Simplify nested conditions for better readability.

The nested if statements can be combined for clearer logic flow.

-    if season_number is not None and episode_number is not None:
-        if (hasattr(episode, 'number') and hasattr(episode, 'season') and
-            episode.season == season_number and episode.number == episode_number):
+    if (season_number is not None and episode_number is not None and
+        hasattr(episode, 'number') and hasattr(episode, 'season') and
+        episode.season == season_number and episode.number == episode_number):
🧰 Tools
🪛 Ruff (0.8.2)

154-156: Use a single if statement instead of nested if statements

(SIM102)


165-175: Simplify next episode tracking logic.

The next episode tracking can be simplified by combining the conditions.

-    if air_time > current_time:
-        if not next_episode or air_time < next_episode:
-            next_episode = air_time
+    if air_time > current_time and (not next_episode or air_time < next_episode):
+        next_episode = air_time

Comment on lines +108 to +180
for episode in sorted(episodes, key=lambda x: (getattr(x, 'season', 0), getattr(x, 'number', 0))):
try:
if not episode.airstamp:
continue

# First try to get air time using network timezone
air_time = None
if (hasattr(show, 'network') and show.network and
hasattr(show.network, 'country') and show.network.country and
hasattr(show.network.country, 'timezone') and show.network.country.timezone and
episode.airdate and episode.airtime):

# Combine airdate and airtime in network timezone
network_tz = ZoneInfo(show.network.country.timezone)
air_datetime = f"{episode.airdate}T{episode.airtime}"
try:
# Parse the time in network timezone
air_time = datetime.fromisoformat(air_datetime).replace(tzinfo=network_tz)
# Only log network time for the target episode
if (season_number is not None and episode_number is not None and
hasattr(episode, 'number') and hasattr(episode, 'season') and
episode.season == season_number and episode.number == episode_number):
logger.debug(f"Network airs show at {air_time} ({show.network.country.timezone})")
except Exception as e:
logger.error(f"Failed to parse network air time: {e}")
air_time = None

# Fallback to airstamp if needed
if not air_time and episode.airstamp:
try:
air_time = datetime.fromisoformat(episode.airstamp.replace('Z', '+00:00'))
if (season_number is not None and episode_number is not None and
hasattr(episode, 'number') and hasattr(episode, 'season') and
episode.season == season_number and episode.number == episode_number):
logger.debug(f"Using UTC airstamp: {air_time}")
except Exception as e:
logger.error(f"Failed to parse airstamp: {e}")
continue

if not air_time:
continue

# Convert to local time
air_time = air_time.astimezone(current_time.tzinfo)

# Check if this is the specific episode we want
if season_number is not None and episode_number is not None:
if (hasattr(episode, 'number') and hasattr(episode, 'season') and
episode.season == season_number and episode.number == episode_number):
# Found our target episode
if hasattr(episode, 'name'):
logger.debug(f"Found S{season_number}E{episode_number} '{episode.name}' airing at {air_time}")
else:
logger.debug(f"Found S{season_number}E{episode_number} airing at {air_time}")
target_episode_time = air_time

# Add all unreleased episodes to our list
if air_time > current_time:
ep_info = {
'air_time': air_time,
'season': getattr(episode, 'season', 0),
'episode': getattr(episode, 'number', 0),
'name': getattr(episode, 'name', '')
}
unreleased_episodes.append(ep_info)
# Track next episode separately
if not next_episode or air_time < next_episode:
next_episode = air_time

except Exception as e:
logger.error(f"Failed to process episode {getattr(episode, 'number', '?')}: {e}")
continue

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

Extract episode processing logic into separate methods.

The episode processing loop is handling multiple responsibilities: parsing air times, finding target episodes, and tracking unreleased episodes. Consider breaking this into smaller, focused methods.

+    def _parse_network_air_time(self, show, episode) -> Optional[datetime]:
+        """Parse air time using network timezone."""
+        if not (hasattr(show, 'network') and show.network and 
+                hasattr(show.network, 'country') and show.network.country and 
+                hasattr(show.network.country, 'timezone') and show.network.country.timezone and
+                episode.airdate and episode.airtime):
+            return None
+        
+        try:
+            network_tz = ZoneInfo(show.network.country.timezone)
+            air_datetime = f"{episode.airdate}T{episode.airtime}"
+            return datetime.fromisoformat(air_datetime).replace(tzinfo=network_tz)
+        except Exception as e:
+            logger.error(f"Failed to parse network air time: {e}")
+            return None

+    def _parse_airstamp(self, episode) -> Optional[datetime]:
+        """Parse air time from airstamp."""
+        if not episode.airstamp:
+            return None
+        try:
+            return datetime.fromisoformat(episode.airstamp.replace('Z', '+00:00'))
+        except Exception as e:
+            logger.error(f"Failed to parse airstamp: {e}")
+            return None

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Ruff (0.8.2)

154-156: Use a single if statement instead of nested if statements

(SIM102)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants