From 96fb5f8b1a959927a8cae3e99bcabb9820c1f0ba Mon Sep 17 00:00:00 2001 From: MD Maksudur Rahman Khan Date: Tue, 9 Sep 2025 01:38:00 +0200 Subject: [PATCH 1/5] feat: add comprehensive TV show support with configurable processing - Add Bazarr episode API client with series enrichment and episode upload - Implement SubSource TV show downloader with intelligent episode matching - Add multi-pattern episode search (S01E01, episode title, scene names) - Extend main workflow to process both movies and TV episodes - Add configurable processing for movies and episodes via config settings - Implement episode tracking with composite keys for series/season/episode - Add comprehensive test suite (24 new tests, 105 total passing) - Update documentation with TV show features and troubleshooting guide - Maintain backward compatibility with existing movie-only functionality --- README.md | 51 ++++- api/bazarr_episodes.py | 211 ++++++++++++++++++ api/tv_shows.py | 348 ++++++++++++++++++++++++++++++ core/config.py | 24 +++ run.py | 327 ++++++++++++++++++++-------- tests/api/test_bazarr_episodes.py | 210 ++++++++++++++++++ tests/api/test_tv_shows.py | 254 ++++++++++++++++++++++ tests/core/test_config.py | 34 ++- 8 files changed, 1360 insertions(+), 99 deletions(-) create mode 100644 api/bazarr_episodes.py create mode 100644 api/tv_shows.py create mode 100644 tests/api/test_bazarr_episodes.py create mode 100644 tests/api/test_tv_shows.py diff --git a/README.md b/README.md index b35d47f..e3cd6c2 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ > โš ๏ธ **Active Development**: This project is under active development. Features may change and stability is not guaranteed. Use at your own risk. -A Python automation tool that connects to your Bazarr instance, identifies movies missing subtitles, and automatically downloads them from SubSource. +A Python automation tool that connects to your Bazarr instance, identifies movies and TV episodes missing subtitles, and automatically downloads them from SubSource. ## Features - ๐ŸŽฌ **Automatic Movie Detection**: Lists all movies missing subtitles from your Bazarr instance +- ๐Ÿ“บ **TV Show Episode Support**: Automatically downloads subtitles for wanted TV show episodes - ๐ŸŒ **SubSource Integration**: Downloads subtitles from SubSource's anonymous API (no account needed) - ๐Ÿ“ค **Seamless Upload**: Automatically uploads downloaded subtitles back to Bazarr - ๐ŸŒ **Multi-language Support**: Supports multiple languages, forced, and hearing impaired subtitles @@ -19,6 +20,7 @@ A Python automation tool that connects to your Bazarr instance, identifies movie - ๐Ÿ“Š **Progress Tracking**: Tracks search history to prevent unnecessary duplicate searches - ๐Ÿงน **Clean Operation**: Automatically cleans up temporary files after successful uploads - โš™๏ธ **Configurable**: External configuration file for easy setup +- ๐Ÿ”ง **Episode Matching**: Intelligent episode matching using season/episode patterns and scene names ## Requirements @@ -63,6 +65,16 @@ A Python automation tool that connects to your Bazarr instance, identifies movie [download] directory = /tmp/downloaded_subtitles + [movies] + # Enable movie subtitle downloads + enabled = true + + [episodes] + # Enable TV show episode subtitle downloads + enabled = true + # Search patterns: season_episode,episode_title,scene_name + search_patterns = season_episode,episode_title,scene_name + [logging] level = INFO file = /var/log/bazarr_subsource.log @@ -143,12 +155,24 @@ The tool's built-in tracking system prevents redundant searches, making frequent ### Download Settings - `directory`: Local directory for temporary subtitle files (default: `/tmp/downloaded_subtitles`) +### Movies Settings +- `enabled`: Enable movie subtitle downloads (default: `true`) + +### Episodes Settings +- `enabled`: Enable TV show episode subtitle downloads (default: `true`) +- `search_patterns`: Episode search patterns, comma-separated (default: `season_episode,episode_title,scene_name`) + - `season_episode`: Search using "Series S01E01" format + - `episode_title`: Search using "Series Episode Title" format + - `scene_name`: Search using scene release names + ### Logging - `level`: Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) - `file`: Log file path (default: `/var/log/bazarr_subsource.log`) +- Log rotation: 10MB max file size, keeps 5 backup files ## How It Works +### Movies 1. **Connect to Bazarr**: Fetches all movies missing subtitles using the `/api/movies/wanted` endpoint 2. **Search SubSource**: For each movie, searches SubSource API for available subtitles 3. **Smart Filtering**: Uses Bazarr's own search intervals to avoid redundant searches @@ -156,6 +180,17 @@ The tool's built-in tracking system prevents redundant searches, making frequent 5. **Upload to Bazarr**: Uploads extracted subtitles back to Bazarr using the `/api/movies/subtitles` endpoint 6. **Cleanup**: Removes temporary files and updates tracking data +### TV Show Episodes +1. **Connect to Bazarr**: Fetches all episodes missing subtitles using the `/api/episodes/wanted` endpoint +2. **Episode Enrichment**: Retrieves series information for each episode from `/api/series` +3. **Multi-Pattern Search**: Searches SubSource using various patterns: + - Series name + S01E01 format + - Series name + episode title + - Scene release names +4. **Episode Matching**: Filters SubSource results to match specific season/episode using regex patterns +5. **Upload to Bazarr**: Uploads matched subtitles using the `/api/episodes/subtitles` endpoint +6. **Cleanup**: Removes temporary files and updates episode tracking data + ## Advanced Features ### Intelligent Retry Logic @@ -253,17 +288,19 @@ bazarr-subsource/ SubSource's anonymous API has rate limits. This tool implements: - 2-second delays between API calls -- Intelligent retry logic based on Bazarr's intervals +- Intelligent retry logic based on Bazarr's intervals for both movies and episodes - Local tracking to minimize unnecessary requests +- Episode-specific search patterns to reduce API calls - No authentication headers or account credentials needed ## Troubleshooting ### Common Issues -**"No movies are currently missing subtitles!"** -- Check if your Bazarr has movies with missing subtitles +**"No movies are currently missing subtitles!" / "No episodes want subtitles."** +- Check if your Bazarr has movies/episodes with missing subtitles - Verify your Bazarr API key and URL are correct +- For episodes: Ensure `episodes_enabled = true` in your config **"Error connecting to Bazarr API"** - Ensure Bazarr is running and accessible @@ -279,6 +316,12 @@ SubSource's anonymous API has rate limits. This tool implements: - The tool creates a default config on first run - Edit `~/.config/bazarr-subsource/config.cfg` with your settings +**Episode subtitles not found** +- Episodes are searched using multiple patterns (S01E01, episode title, scene name) +- SubSource has limited TV show coverage compared to movies +- Check if the series name matches exactly in both Bazarr and SubSource +- Some episodes may not have subtitles available on SubSource + **Cron job not running or failing** - Check cron service is running: `sudo systemctl status cron` - Verify cron job syntax: `crontab -l` diff --git a/api/bazarr_episodes.py b/api/bazarr_episodes.py new file mode 100644 index 0000000..d86ddbd --- /dev/null +++ b/api/bazarr_episodes.py @@ -0,0 +1,211 @@ +""" +Bazarr API client for TV show episodes. +""" + +import logging +from typing import Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + + +class BazarrEpisodeClient: + """Bazarr API client for episode operations.""" + + def __init__(self, base_url: str, api_key: str, username: str, password: str): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.session = requests.Session() + + # Set up authentication + if username and password: + self.session.auth = (username, password) + + self.session.headers.update( + { + "X-API-KEY": api_key, + "Content-Type": "application/json", + } + ) + + def get_wanted_episodes(self, start: int = 0, length: int = -1) -> List[Dict]: + """ + Get list of wanted episodes from Bazarr. + + Args: + start: Paging start integer + length: Paging length integer (-1 for all) + + Returns: + List of wanted episode dictionaries + """ + try: + url = f"{self.base_url}/api/episodes/wanted" + params = {"start": start, "length": length} + + response = self.session.get(url, params=params, timeout=30) + response.raise_for_status() + + data = response.json() + episodes = data.get("data", []) if isinstance(data, dict) else data + + # Enrich episode data with series information + enriched_episodes = [] + for episode in episodes: + enriched_episode = self._enrich_episode_data(episode) + if enriched_episode: + enriched_episodes.append(enriched_episode) + + logger.info(f"Found {len(enriched_episodes)} wanted episodes") + return enriched_episodes + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching wanted episodes: {e}") + return [] + + def _enrich_episode_data(self, episode: Dict) -> Optional[Dict]: + """ + Enrich episode data with additional series information. + + Args: + episode: Raw episode data from Bazarr + + Returns: + Enriched episode data or None if series info unavailable + """ + series_id = episode.get("sonarrSeriesId") or episode.get("seriesId") + if not series_id: + logger.warning( + f"No series ID found for episode: {episode.get('title', 'Unknown')}" + ) + return episode + + try: + # Get series information + series_info = self.get_series_info(series_id) + if series_info: + episode["seriesTitle"] = series_info.get("title", "Unknown Series") + episode["seriesYear"] = series_info.get("year") + episode["seriesImdb"] = series_info.get("imdbId") + + return episode + + except Exception as e: + logger.warning(f"Could not enrich episode data: {e}") + return episode + + def get_series_info(self, series_id: int) -> Optional[Dict]: + """ + Get series information from Bazarr. + + Args: + series_id: Series ID + + Returns: + Series information dictionary or None + """ + try: + url = f"{self.base_url}/api/series" + params = {"seriesid[]": series_id} + + response = self.session.get(url, params=params, timeout=30) + response.raise_for_status() + + data = response.json() + series_list = data.get("data", []) if isinstance(data, dict) else data + + # Find the series with matching ID + for series in series_list: + if ( + series.get("sonarrSeriesId") == series_id + or series.get("seriesId") == series_id + ): + return series + + return None + + except requests.exceptions.RequestException as e: + logger.warning(f"Error fetching series info for ID {series_id}: {e}") + return None + + def upload_episode_subtitle( + self, + series_id: int, + episode_id: int, + language: str, + subtitle_file: str, + forced: bool = False, + hi: bool = False, + ) -> bool: + """ + Upload a subtitle file for an episode to Bazarr. + + Args: + series_id: Series ID + episode_id: Episode ID + language: Language code (e.g., 'en') + subtitle_file: Path to subtitle file + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + + Returns: + True if upload successful, False otherwise + """ + try: + url = f"{self.base_url}/api/episodes/subtitles" + + params = { + "seriesid": series_id, + "episodeid": episode_id, + "language": language, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + } + + with open(subtitle_file, "rb") as f: + files = {"file": (subtitle_file, f, "text/plain")} + + response = self.session.post( + url, params=params, files=files, timeout=60 + ) + response.raise_for_status() + + logger.info( + f"Successfully uploaded subtitle for episode {episode_id}: " + f"{subtitle_file}" + ) + return True + + except requests.exceptions.RequestException as e: + logger.error(f"Error uploading episode subtitle: {e}") + return False + except FileNotFoundError: + logger.error(f"Subtitle file not found: {subtitle_file}") + return False + + def get_search_interval(self) -> int: + """ + Get search interval from Bazarr settings. + + Returns: + Search interval in hours (default 24) + """ + try: + url = f"{self.base_url}/api/system/settings" + response = self.session.get(url, timeout=30) + response.raise_for_status() + + settings = response.json() + + # Look for episode search interval setting + # This might be under different keys depending on Bazarr version + interval = settings.get("general", {}).get("episode_search_interval", 24) + if isinstance(interval, str): + interval = int(interval) + + return max(1, interval) # Minimum 1 hour + + except Exception as e: + logger.warning(f"Could not get search interval from Bazarr: {e}") + return 24 # Default fallback diff --git a/api/tv_shows.py b/api/tv_shows.py new file mode 100644 index 0000000..35c4ae0 --- /dev/null +++ b/api/tv_shows.py @@ -0,0 +1,348 @@ +""" +TV Show SubSource API client for downloading episode subtitles. +""" + +import logging +import re +import time +from typing import Dict, List, Optional, Tuple + +import requests + +from core.tracking import SubtitleTracker + +logger = logging.getLogger(__name__) + + +class TVShowSubSourceDownloader: + """SubSource TV show subtitle downloader.""" + + def __init__(self, api_url: str, download_dir: str, bazarr=None): + self.api_url = api_url + self.download_dir = download_dir + self.session = requests.Session() + self.tracker = SubtitleTracker() + self.bazarr = bazarr + self._search_interval_hours = None + + def _get_search_interval_hours(self) -> int: + """Get search interval from Bazarr or use default.""" + if self._search_interval_hours is not None: + return self._search_interval_hours + + # Try to get from Bazarr API if available + if self.bazarr: + try: + search_interval = self.bazarr.get_search_interval() + self._search_interval_hours = search_interval + return search_interval + except Exception as e: + logger.warning(f"Could not get search interval from Bazarr: {e}") + + # Default fallback + self._search_interval_hours = 24 + return self._search_interval_hours + + def _generate_episode_search_queries(self, episode: Dict) -> List[str]: + """ + Generate search queries for an episode. + + Args: + episode: Episode data from Bazarr + + Returns: + List of search query strings + """ + series_title = episode.get("seriesTitle", "") + episode_title = episode.get("title", "") + season = episode.get("season", 0) + episode_num = episode.get("episode", 0) + scene_name = episode.get("sceneName", "") + + queries = [] + + if series_title: + # Primary: Series + S01E01 format + if season and episode_num: + queries.append(f"{series_title} S{season:02d}E{episode_num:02d}") + + # Secondary: Series + episode title + if episode_title: + queries.append(f"{series_title} {episode_title}") + + # Tertiary: Just series name (less specific) + queries.append(series_title) + + # Quaternary: Scene name if available + if scene_name: + # Extract show name from scene name + scene_clean = re.sub(r"[.\-_]", " ", scene_name) + queries.append(scene_clean) + + return queries + + def _extract_episode_info_from_subtitle( + self, subtitle: Dict + ) -> Tuple[Optional[int], Optional[int]]: + """ + Extract season/episode info from subtitle release info. + + Args: + subtitle: Subtitle data from SubSource + + Returns: + Tuple of (season, episode) or (None, None) if not found + """ + release_info = subtitle.get("release_info", "") + + # Look for S01E01 pattern + season_episode_match = re.search(r"[Ss](\d+)[Ee](\d+)", release_info) + if season_episode_match: + season = int(season_episode_match.group(1)) + episode = int(season_episode_match.group(2)) + return season, episode + + # Look for 1x01 pattern + alt_pattern = re.search(r"(\d+)x(\d+)", release_info) + if alt_pattern: + season = int(alt_pattern.group(1)) + episode = int(alt_pattern.group(2)) + return season, episode + + return None, None + + def _is_subtitle_match(self, subtitle: Dict, target_episode: Dict) -> bool: + """ + Check if a subtitle matches the target episode. + + Args: + subtitle: Subtitle data from SubSource + target_episode: Episode data from Bazarr + + Returns: + True if subtitle matches episode + """ + target_season = target_episode.get("season") + target_episode_num = target_episode.get("episode") + + if not target_season or not target_episode_num: + return False + + sub_season, sub_episode = self._extract_episode_info_from_subtitle(subtitle) + + if sub_season == target_season and sub_episode == target_episode_num: + return True + + return False + + def search_episode_subtitles( + self, episode: Dict, language: str = "english" + ) -> List[Dict]: + """ + Search for subtitles for a specific episode. + + Args: + episode: Episode data from Bazarr + language: Subtitle language + + Returns: + List of matching subtitle results + """ + series_title = episode.get("seriesTitle", "Unknown") + season = episode.get("season", 0) + episode_num = episode.get("episode", 0) + + print( + f" Searching SubSource for: {series_title} " + f"S{season:02d}E{episode_num:02d}" + ) + + queries = self._generate_episode_search_queries(episode) + all_results = [] + + for query in queries: + try: + print(f" Trying query: {query}") + + # Use movie search with includeSeasons=True to get TV content + search_url = f"{self.api_url}/movie/search" + search_payload = { + "query": query, + "signal": {}, + "includeSeasons": True, # Include TV shows + "limit": 15, + } + + response = self.session.post( + search_url, json=search_payload, timeout=15 + ) + response.raise_for_status() + + time.sleep(2) # Rate limiting + + search_data = response.json() + search_results = search_data.get("results", []) + + print(f" Found {len(search_results)} result(s)") + + # Look for both TV series and movies that might match + for result in search_results: + result_title = result.get("title", "").lower() + series_title_lower = series_title.lower() + + # Skip if title doesn't match series + if series_title_lower not in result_title: + continue + + link = result.get("link", "") + if not link: + continue + + # For TV series, we can't directly access episodes + # For movies/specials, we can access subtitles + if "/subtitles/" in link: + # This is a movie/special - get subtitles directly + subtitles_url = f"{self.api_url}{link}" + params = {"language": language.lower(), "sort_by_date": "false"} + + time.sleep(2) # Rate limiting + + sub_response = self.session.get( + subtitles_url, params=params, timeout=15 + ) + sub_response.raise_for_status() + + subtitles_data = sub_response.json() + if isinstance(subtitles_data, list): + subtitles = subtitles_data + else: + subtitles = subtitles_data.get("subtitles", []) + + # Filter subtitles that match our episode + for subtitle in subtitles: + if self._is_subtitle_match(subtitle, episode): + subtitle["source_query"] = query + subtitle["source_link"] = link + all_results.append(subtitle) + + matching_count = len( + [ + s + for s in subtitles + if self._is_subtitle_match(s, episode) + ] + ) + print( + f" Found {matching_count} matching episode subtitles" + ) + + # For TV series links, we would need a different approach + # but SubSource doesn't provide episode-level access + + except requests.exceptions.RequestException as e: + print(f" Error searching with query '{query}': {e}") + continue + + # Remove duplicates based on subtitle ID + unique_results = [] + seen_ids = set() + for result in all_results: + sub_id = result.get("id") + if sub_id and sub_id not in seen_ids: + seen_ids.add(sub_id) + unique_results.append(result) + + print(f" Found {len(unique_results)} unique matching subtitles") + + if not unique_results: + # Record failure for tracking + episode_key = f"{series_title}:S{season:02d}E{episode_num:02d}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + + return unique_results + + def download_subtitle(self, subtitle: Dict, filename: str) -> Optional[str]: + """ + Download a subtitle file. + + Args: + subtitle: Subtitle data from SubSource + filename: Local filename for the subtitle + + Returns: + Path to downloaded file or None if failed + """ + # Reuse the movie downloader logic from subsource.py + # This is the same download process + from api.subsource import SubSourceDownloader + + # Create a temporary movie downloader to reuse download logic + temp_downloader = SubSourceDownloader( + self.api_url, self.download_dir, self.bazarr + ) + return temp_downloader.download_subtitle(subtitle, filename) + + def get_subtitle_for_episode(self, episode: Dict) -> Tuple[List[str], int]: + """ + Download subtitles for an episode. + + Args: + episode: Episode dictionary from Bazarr API + + Returns: + Tuple of (downloaded subtitle file paths, number of skipped subtitles) + """ + series_title = episode.get("seriesTitle", "Unknown") + season = episode.get("season", 0) + episode_num = episode.get("episode", 0) + missing_subs = episode.get("missing_subtitles", []) + + episode_key = f"{series_title}:S{season:02d}E{episode_num:02d}" + + downloaded_files = [] + skipped_count = 0 + + print(f" Processing: {episode_key}") + + for sub in missing_subs: + lang_name = sub.get("name", "Unknown") + lang_code = sub.get("code2", "en") + + print(f" Looking for {lang_name} subtitle...") + + # Check if we should skip this search based on recent failures + search_interval = self._get_search_interval_hours() + if self.tracker.should_skip_search( + episode_key, 0, lang_name.lower(), search_interval + ): + print( + f" Skipping {lang_name} subtitle " + f"(last tried within {search_interval}h interval)" + ) + skipped_count += 1 + continue + + # Search for subtitles + results = self.search_episode_subtitles(episode, lang_name.lower()) + + if not results: + print(f" No subtitles found for {lang_name}") + continue + + # Take the best result (first one) + best_result = results[0] + + # Download subtitle + downloaded_file = self.download_subtitle( + best_result, f"temp_episode_{lang_code}.srt" + ) + if downloaded_file: + downloaded_files.append(downloaded_file) + print(f" โœ“ Downloaded {lang_name} subtitle") + else: + self.tracker.record_download_failure( + episode_key, 0, lang_name.lower(), "Download failed" + ) + print(f" โœ— Failed to download {lang_name} subtitle") + + return downloaded_files, skipped_count diff --git a/core/config.py b/core/config.py index 7021196..5d0bc72 100644 --- a/core/config.py +++ b/core/config.py @@ -44,6 +44,13 @@ def load_config(): "password": config.get("auth", "password"), "subsource_api_url": config.get("subsource", "api_url"), "download_directory": config.get("download", "directory"), + "movies_enabled": config.getboolean("movies", "enabled", fallback=True), + "episodes_enabled": config.getboolean("episodes", "enabled", fallback=True), + "episodes_search_patterns": config.get( + "episodes", + "search_patterns", + fallback="season_episode,episode_title,scene_name", + ), "log_level": config.get("logging", "level", fallback="INFO"), "log_file": config.get("logging", "file", fallback="bazarr_subsource.log"), } @@ -71,6 +78,13 @@ def create_default_config(config_file: Path): config["download"] = {"directory": "/tmp/downloaded_subtitles"} + config["movies"] = {"enabled": "true"} + + config["episodes"] = { + "enabled": "true", + "search_patterns": "season_episode,episode_title,scene_name", + } + config["logging"] = {"level": "INFO", "file": "/var/log/bazarr_subsource.log"} with open(config_file, "w") as f: @@ -102,6 +116,16 @@ def create_default_config(config_file: Path): f.write("[download]\n") f.write("directory = /tmp/downloaded_subtitles\n\n") + f.write("[movies]\n") + f.write("# Enable movie subtitle downloads\n") + f.write("enabled = true\n\n") + + f.write("[episodes]\n") + f.write("# Enable TV show episode subtitle downloads\n") + f.write("enabled = true\n") + f.write("# Search patterns: season_episode,episode_title,scene_name\n") + f.write("search_patterns = season_episode,episode_title,scene_name\n\n") + f.write("[logging]\n") f.write("level = INFO\n") f.write("file = /var/log/bazarr_subsource.log\n") diff --git a/run.py b/run.py index 8329d10..af22b4d 100644 --- a/run.py +++ b/run.py @@ -73,126 +73,265 @@ def main(): config["password"], ) - # Fetch wanted movies - data = bazarr.get_wanted_movies() - if data is None: - sys.exit(1) + # Process movies if enabled + movies = [] + if config.get("movies_enabled", True): + # Fetch wanted movies + data = bazarr.get_wanted_movies() + if data is None: + sys.exit(1) - # Extract movies from response - movies = data.get("data", []) + # Extract movies from response + movies = data.get("data", []) - print(f"Done!\nFound {len(movies)} wanted movies") + print(f"Done!\nFound {len(movies)} wanted movies") - if not movies: - print("No movies are currently missing subtitles!") - return + if not movies: + print("No movies are currently missing subtitles!") + else: + print("Movie processing disabled in configuration.") - print("\nWanted Movies:") - print("-" * 40) + # Continue with movie processing if we have movies + if movies: + print("\nWanted Movies:") + print("-" * 40) - # Display each movie - for movie in movies: - print(format_movie_info(movie)) + # Display each movie + for movie in movies: + print(format_movie_info(movie)) - print(f"\nTotal: {len(movies)} movies need subtitles") + print(f"\nTotal: {len(movies)} movies need subtitles") - # Automatically proceed with downloading subtitles - print("\n" + "=" * 50) - print("Automatically downloading missing subtitles from SubSource...") - - # Initialize SubSource downloader - print("\nInitializing SubSource downloader...") - downloader = SubSourceDownloader( - config["subsource_api_url"], - config["download_directory"], - bazarr, # Pass Bazarr client for API calls - ) + # Automatically proceed with downloading subtitles + print("\n" + "=" * 50) + print("Automatically downloading missing subtitles from SubSource...") - print(f"Download directory: {config['download_directory']}") + # Initialize SubSource downloader + print("\nInitializing SubSource downloader...") + downloader = SubSourceDownloader( + config["subsource_api_url"], + config["download_directory"], + bazarr, # Pass Bazarr client for API calls + ) - # Clean up obsolete tracking entries - print("Cleaning up obsolete tracking entries...") - removed_count = downloader.tracker.cleanup_obsolete_movies(movies) - if removed_count > 0: - print(f"Removed {removed_count} obsolete movie(s) from tracking database") + print(f"Download directory: {config['download_directory']}") - print("\nStarting subtitle downloads...") - print("=" * 50) + # Clean up obsolete tracking entries + print("Cleaning up obsolete tracking entries...") + removed_count = downloader.tracker.cleanup_obsolete_movies(movies) + if removed_count > 0: + print( + f"Removed {removed_count} obsolete movie(s) from tracking database" + ) + + print("\nStarting subtitle downloads...") + print("=" * 50) + # Initialize counters (outside if block for summary) total_downloads = 0 successful_uploads = 0 subtitles_skipped = 0 - # Process each movie - for i, movie in enumerate(movies, 1): - print(f"\n[{i}/{len(movies)}] Processing movie:") - - # Download subtitles for this movie - downloaded_files, movie_skipped = downloader.get_subtitle_for_movie(movie) - subtitles_skipped += movie_skipped - - if not downloaded_files: - print(" No subtitles downloaded for this movie.") - continue - - total_downloads += len(downloaded_files) - - # Get movie info for upload - radarr_id = movie.get("radarrId", movie.get("radarrid")) - if not radarr_id: - print(" โœ— No Radarr ID found, cannot upload to Bazarr") - continue - - # Upload each downloaded subtitle to Bazarr - print(" Uploading subtitles to Bazarr...") - missing_subs = movie.get("missing_subtitles", []) - - for j, subtitle_file in enumerate(downloaded_files): - if j < len(missing_subs): - sub_info = missing_subs[j] - lang_code = sub_info.get("code2", "en") - forced = sub_info.get("forced", False) - hi = sub_info.get("hi", False) - - if bazarr.upload_subtitle_to_bazarr( - radarr_id, subtitle_file, lang_code, forced, hi - ): - successful_uploads += 1 - - # Clean up tracking database for successful download - title = movie.get("title", "Unknown") - year = movie.get("year", 0) - lang_name = sub_info.get("name", "Unknown") - downloader.tracker.remove_successful_download( - title, year, lang_name.lower() - ) + # Process movies if we have them + if movies: + # Process each movie + for i, movie in enumerate(movies, 1): + print(f"\n[{i}/{len(movies)}] Processing movie:") + + # Download subtitles for this movie + downloaded_files, movie_skipped = downloader.get_subtitle_for_movie( + movie + ) + subtitles_skipped += movie_skipped + + if not downloaded_files: + print(" No subtitles downloaded for this movie.") + continue + + total_downloads += len(downloaded_files) + + # Get movie info for upload + radarr_id = movie.get("radarrId", movie.get("radarrid")) + if not radarr_id: + print(" โœ— No Radarr ID found, cannot upload to Bazarr") + continue + + # Upload each downloaded subtitle to Bazarr + print(" Uploading subtitles to Bazarr...") + missing_subs = movie.get("missing_subtitles", []) + + for j, subtitle_file in enumerate(downloaded_files): + if j < len(missing_subs): + sub_info = missing_subs[j] + lang_code = sub_info.get("code2", "en") + forced = sub_info.get("forced", False) + hi = sub_info.get("hi", False) + + if bazarr.upload_subtitle_to_bazarr( + radarr_id, subtitle_file, lang_code, forced, hi + ): + successful_uploads += 1 + + # Clean up tracking database for successful download + title = movie.get("title", "Unknown") + year = movie.get("year", 0) + lang_name = sub_info.get("name", "Unknown") + downloader.tracker.remove_successful_download( + title, year, lang_name.lower() + ) + + # Remove local file after successful upload + try: + os.remove(subtitle_file) + print(f" Cleaned up local file: {subtitle_file}") + except OSError: + pass + + # Small delay between movies + if i < len(movies): + time.sleep(1) + + # Process TV show episodes if enabled + episodes_processed = 0 + episodes_downloads = 0 + episodes_uploads = 0 + episodes_skipped = 0 + + if config.get("episodes_enabled", True): + print("\n" + "=" * 50) + print("PROCESSING TV SHOW EPISODES") + print("=" * 50) + + from api.bazarr_episodes import BazarrEpisodeClient + from api.tv_shows import TVShowSubSourceDownloader + + # Initialize episode clients + episode_client = BazarrEpisodeClient( + config["bazarr_url"], + config["api_key"], + config["username"], + config["password"], + ) + + tv_downloader = TVShowSubSourceDownloader( + config["subsource_api_url"], + config["download_directory"], + episode_client, + ) - # Remove local file after successful upload - try: - os.remove(subtitle_file) - print(f" Cleaned up local file: {subtitle_file}") - except OSError: - pass + # Get wanted episodes + episodes = episode_client.get_wanted_episodes() + episodes_processed = len(episodes) + + if not episodes: + print("No episodes want subtitles.") + else: + print(f"Found {len(episodes)} episode(s) wanting subtitles") + + # Clean up obsolete episode tracking entries + print("Cleaning up obsolete episode tracking entries...") + removed_count = tv_downloader.tracker.cleanup_obsolete_movies(episodes) + if removed_count > 0: + print( + f"Removed {removed_count} obsolete episode(s) " + f"from tracking database" + ) + + for i, episode in enumerate(episodes, 1): + print(f"\n[{i}/{len(episodes)}] Processing episode...") + + # Download subtitles for this episode + downloaded_files, skipped_count = ( + tv_downloader.get_subtitle_for_episode(episode) + ) + episodes_downloads += len(downloaded_files) + episodes_skipped += skipped_count + + # Upload each downloaded subtitle to Bazarr + for subtitle_file in downloaded_files: + # Extract subtitle info from the episode and file + series_id = episode.get("sonarrSeriesId") or episode.get( + "seriesId" + ) + episode_id = episode.get("sonarrEpisodeId") or episode.get( + "episodeId" + ) - # Small delay between movies - if i < len(movies): - time.sleep(1) + # Determine language from filename or default to first + # missing subtitle + missing_subs = episode.get("missing_subtitles", []) + if missing_subs: + lang_code = missing_subs[0].get("code2", "en") + lang_name = missing_subs[0].get("name", "Unknown") + else: + lang_code = "en" + lang_name = "English" + + if series_id and episode_id: + sub_info = {"name": lang_name, "code2": lang_code} + + # Upload to Bazarr + if episode_client.upload_episode_subtitle( + series_id, episode_id, lang_code, subtitle_file + ): + episodes_uploads += 1 + print(f" โœ“ Uploaded {lang_name} subtitle to Bazarr") + + # Clean up tracking database for successful download + series_title = episode.get("seriesTitle", "Unknown") + season = episode.get("season", 0) + episode_num = episode.get("episode", 0) + episode_key = ( + f"{series_title}:S{season:02d}E{episode_num:02d}" + ) + tv_downloader.tracker.remove_successful_download( + episode_key, 0, lang_name.lower() + ) + + # Remove local file after successful upload + try: + os.remove(subtitle_file) + print(f" Cleaned up local file: {subtitle_file}") + except OSError: + pass + else: + print( + f" โœ— Failed to upload {lang_name} " + f"subtitle to Bazarr" + ) + else: + print(" โœ— Missing series_id or episode_id for upload") + + # Small delay between episodes + if i < len(episodes): + time.sleep(1) # Summary print("\n" + "=" * 50) print("DOWNLOAD SUMMARY") print("=" * 50) print(f"Movies processed: {len(movies)}") - print(f"Subtitles downloaded: {total_downloads}") - print(f"Subtitles uploaded to Bazarr: {successful_uploads}") - print(f"Subtitles skipped: {subtitles_skipped}") - - if successful_uploads > 0: - print(f"\nโœ“ Successfully processed {successful_uploads} subtitle(s)!") + print(f"Episodes processed: {episodes_processed}") + print(f"Movie subtitles downloaded: {total_downloads}") + print(f"Episode subtitles downloaded: {episodes_downloads}") + print(f"Movie subtitles uploaded to Bazarr: {successful_uploads}") + print(f"Episode subtitles uploaded to Bazarr: {episodes_uploads}") + print(f"Movie subtitles skipped: {subtitles_skipped}") + print(f"Episode subtitles skipped: {episodes_skipped}") + + total_all_uploads = successful_uploads + episodes_uploads + + if total_all_uploads > 0: + print(f"\nโœ“ Successfully processed {total_all_uploads} subtitle(s)!") + if successful_uploads > 0: + print(f" - Movies: {successful_uploads}") + if episodes_uploads > 0: + print(f" - Episodes: {episodes_uploads}") print("Check your Bazarr interface to verify the subtitles were added.") logger.info( f"Execution completed successfully. " - f"{successful_uploads} subtitles uploaded." + f"{total_all_uploads} subtitles uploaded " + f"({successful_uploads} movies, {episodes_uploads} episodes)." ) else: print("\nโš  No subtitles were successfully uploaded.") diff --git a/tests/api/test_bazarr_episodes.py b/tests/api/test_bazarr_episodes.py new file mode 100644 index 0000000..9c15993 --- /dev/null +++ b/tests/api/test_bazarr_episodes.py @@ -0,0 +1,210 @@ +""" +Test cases for Bazarr Episode API client. +""" + +import os +import tempfile +import unittest +from unittest.mock import Mock, patch + +from api.bazarr_episodes import BazarrEpisodeClient + + +class TestBazarrEpisodeClient(unittest.TestCase): + """Test cases for Bazarr Episode API client.""" + + def setUp(self): + """Set up test client.""" + self.client = BazarrEpisodeClient( + "http://localhost:6767", "test-api-key", "testuser", "testpass" + ) + + @patch("api.bazarr_episodes.requests.Session.get") + def test_get_wanted_episodes_success(self, mock_get): + """Test successful retrieval of wanted episodes.""" + # Mock response data + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrEpisodeId": 123, + "sonarrSeriesId": 456, + "title": "Pilot", + "season": 1, + "episode": 1, + "missing_subtitles": [{"name": "English", "code2": "en"}], + "sceneName": "Breaking.Bad.S01E01.1080p.BluRay.x264-REWARD", + }, + { + "sonarrEpisodeId": 124, + "sonarrSeriesId": 456, + "title": "Cat's in the Bag", + "season": 1, + "episode": 2, + "missing_subtitles": [{"name": "Spanish", "code2": "es"}], + }, + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + # Mock series enrichment + with patch.object(self.client, "_enrich_episode_data", side_effect=lambda x: x): + episodes = self.client.get_wanted_episodes() + + self.assertEqual(len(episodes), 2) + self.assertEqual(episodes[0]["title"], "Pilot") + self.assertEqual(episodes[0]["season"], 1) + self.assertEqual(episodes[0]["episode"], 1) + mock_get.assert_called_once() + + @patch("api.bazarr_episodes.requests.Session.get") + def test_get_wanted_episodes_empty(self, mock_get): + """Test retrieval when no episodes want subtitles.""" + mock_response = Mock() + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + episodes = self.client.get_wanted_episodes() + + self.assertEqual(len(episodes), 0) + + @patch("api.bazarr_episodes.requests.Session.get") + def test_get_series_info_success(self, mock_get): + """Test successful series info retrieval.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrSeriesId": 456, + "title": "Breaking Bad", + "year": 2008, + "imdbId": "tt0903747", + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + series_info = self.client.get_series_info(456) + + self.assertIsNotNone(series_info) + self.assertEqual(series_info["title"], "Breaking Bad") + self.assertEqual(series_info["year"], 2008) + + @patch("api.bazarr_episodes.requests.Session.get") + def test_get_series_info_not_found(self, mock_get): + """Test series info when series not found.""" + mock_response = Mock() + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + series_info = self.client.get_series_info(999) + + self.assertIsNone(series_info) + + def test_enrich_episode_data_success(self): + """Test episode data enrichment.""" + episode = { + "sonarrEpisodeId": 123, + "sonarrSeriesId": 456, + "title": "Pilot", + } + + with patch.object(self.client, "get_series_info") as mock_get_series: + mock_get_series.return_value = { + "title": "Breaking Bad", + "year": 2008, + "imdbId": "tt0903747", + } + + enriched = self.client._enrich_episode_data(episode) + + self.assertEqual(enriched["seriesTitle"], "Breaking Bad") + self.assertEqual(enriched["seriesYear"], 2008) + self.assertEqual(enriched["seriesImdb"], "tt0903747") + + def test_enrich_episode_data_no_series_id(self): + """Test episode enrichment when no series ID.""" + episode = { + "sonarrEpisodeId": 123, + "title": "Pilot", + } + + enriched = self.client._enrich_episode_data(episode) + + # Should return original episode + self.assertEqual(enriched, episode) + + @patch("api.bazarr_episodes.requests.Session.post") + def test_upload_episode_subtitle_success(self, mock_post): + """Test successful episode subtitle upload.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Create temporary subtitle file + with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f: + f.write("1\n00:00:01,000 --> 00:00:02,000\nTest subtitle\n") + temp_file = f.name + + try: + result = self.client.upload_episode_subtitle( + series_id=456, + episode_id=123, + language="en", + subtitle_file=temp_file, + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_post.assert_called_once() + + # Check call parameters + call_args = mock_post.call_args + self.assertIn("seriesid", call_args[1]["params"]) + self.assertEqual(call_args[1]["params"]["seriesid"], 456) + self.assertEqual(call_args[1]["params"]["episodeid"], 123) + self.assertEqual(call_args[1]["params"]["language"], "en") + + finally: + os.unlink(temp_file) + + def test_upload_episode_subtitle_file_not_found(self): + """Test subtitle upload with non-existent file.""" + result = self.client.upload_episode_subtitle( + series_id=456, + episode_id=123, + language="en", + subtitle_file="/non/existent/file.srt", + ) + + self.assertFalse(result) + + @patch("api.bazarr_episodes.requests.Session.get") + def test_get_search_interval_success(self, mock_get): + """Test getting search interval from Bazarr.""" + mock_response = Mock() + mock_response.json.return_value = {"general": {"episode_search_interval": 12}} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + interval = self.client.get_search_interval() + + self.assertEqual(interval, 12) + + @patch("api.bazarr_episodes.requests.Session.get") + def test_get_search_interval_fallback(self, mock_get): + """Test search interval fallback when API fails.""" + mock_get.side_effect = Exception("API Error") + + interval = self.client.get_search_interval() + + self.assertEqual(interval, 24) # Default fallback + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/api/test_tv_shows.py b/tests/api/test_tv_shows.py new file mode 100644 index 0000000..ceca2a7 --- /dev/null +++ b/tests/api/test_tv_shows.py @@ -0,0 +1,254 @@ +""" +Test cases for TV Show SubSource API client. +""" + +import tempfile +import unittest +from unittest.mock import Mock, patch + +from api.tv_shows import TVShowSubSourceDownloader + + +class TestTVShowSubSourceDownloader(unittest.TestCase): + """Test cases for TV Show SubSource API client.""" + + def setUp(self): + """Set up test downloader.""" + with tempfile.TemporaryDirectory() as temp_dir: + self.temp_dir = temp_dir + self.downloader = TVShowSubSourceDownloader( + "https://api.test.com", temp_dir + ) + + def test_generate_episode_search_queries(self): + """Test generation of episode search queries.""" + episode = { + "seriesTitle": "Breaking Bad", + "title": "Pilot", + "season": 1, + "episode": 1, + "sceneName": "Breaking.Bad.S01E01.1080p.BluRay.x264-REWARD", + } + + queries = self.downloader._generate_episode_search_queries(episode) + + self.assertEqual(len(queries), 4) + self.assertIn("Breaking Bad S01E01", queries) + self.assertIn("Breaking Bad Pilot", queries) + self.assertIn("Breaking Bad", queries) + + def test_generate_episode_search_queries_minimal(self): + """Test query generation with minimal episode data.""" + episode = {"seriesTitle": "Test Show"} + + queries = self.downloader._generate_episode_search_queries(episode) + + self.assertEqual(queries, ["Test Show"]) + + def test_extract_episode_info_s01e01_format(self): + """Test episode info extraction from S01E01 format.""" + subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertEqual(season, 1) + self.assertEqual(episode, 1) + + def test_extract_episode_info_1x01_format(self): + """Test episode info extraction from 1x01 format.""" + subtitle = {"release_info": "Breaking.Bad.1x01.720p.HDTV.x264-CTU"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertEqual(season, 1) + self.assertEqual(episode, 1) + + def test_extract_episode_info_no_match(self): + """Test episode info extraction when no pattern matches.""" + subtitle = {"release_info": "Breaking.Bad.Complete.Series.BluRay"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertIsNone(season) + self.assertIsNone(episode) + + def test_is_subtitle_match_success(self): + """Test subtitle matching success.""" + subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} + episode = {"season": 1, "episode": 1} + + is_match = self.downloader._is_subtitle_match(subtitle, episode) + + self.assertTrue(is_match) + + def test_is_subtitle_match_failure(self): + """Test subtitle matching failure.""" + subtitle = {"release_info": "Breaking.Bad.S01E02.720p.BluRay.x264-REWARD"} + episode = {"season": 1, "episode": 1} + + is_match = self.downloader._is_subtitle_match(subtitle, episode) + + self.assertFalse(is_match) + + def test_is_subtitle_match_no_episode_info(self): + """Test subtitle matching when episode has no season/episode.""" + subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} + episode = {"title": "Pilot"} + + is_match = self.downloader._is_subtitle_match(subtitle, episode) + + self.assertFalse(is_match) + + @patch("api.tv_shows.requests.Session.post") + @patch("api.tv_shows.requests.Session.get") + @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") + def test_search_episode_subtitles_success(self, mock_interval, mock_get, mock_post): + """Test successful episode subtitle search.""" + # Mock interval hours + mock_interval.return_value = 24 + + # Mock movie search response + movie_search_response = Mock() + movie_search_response.json.return_value = { + "results": [ + { + "title": "Breaking Bad S01E01", + "link": "/subtitles/breaking-bad-s01e01-pilot", + "type": "movie", + } + ] + } + movie_search_response.raise_for_status.return_value = None + mock_post.return_value = movie_search_response + + # Mock subtitle fetch response + subtitle_response = Mock() + subtitle_response.json.return_value = [ + { + "id": "12345", + "language": "English", + "release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD", + } + ] + subtitle_response.raise_for_status.return_value = None + mock_get.return_value = subtitle_response + + episode = { + "seriesTitle": "Breaking Bad", + "title": "Pilot", + "season": 1, + "episode": 1, + } + + results = self.downloader.search_episode_subtitles(episode, "english") + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "12345") + # Source query could be any of the tried queries due to deduplication + self.assertIn( + results[0]["source_query"], + ["Breaking Bad S01E01", "Breaking Bad Pilot", "Breaking Bad"], + ) + + @patch("api.tv_shows.requests.Session.post") + @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") + def test_search_episode_subtitles_no_results(self, mock_interval, mock_post): + """Test episode search with no results.""" + # Mock interval hours + mock_interval.return_value = 24 + + # Mock empty search response + mock_response = Mock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + episode = { + "seriesTitle": "Nonexistent Show", + "season": 1, + "episode": 1, + } + + results = self.downloader.search_episode_subtitles(episode) + + self.assertEqual(len(results), 0) + + @patch.object(TVShowSubSourceDownloader, "search_episode_subtitles") + @patch.object(TVShowSubSourceDownloader, "download_subtitle") + @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") + def test_get_subtitle_for_episode(self, mock_interval, mock_download, mock_search): + """Test getting subtitles for an episode.""" + # Mock interval hours + mock_interval.return_value = 24 + + # Mock search results + mock_search.return_value = [ + {"id": "12345", "language": "English"}, + ] + + # Mock download + mock_download.return_value = "/path/to/subtitle.srt" + + # Mock tracker to not skip searches + with patch.object( + self.downloader.tracker, "should_skip_search", return_value=False + ): + episode = { + "seriesTitle": "Breaking Bad", + "season": 1, + "episode": 1, + "missing_subtitles": [{"name": "English", "code2": "en"}], + } + + downloaded_files, skipped_count = self.downloader.get_subtitle_for_episode( + episode + ) + + self.assertEqual(len(downloaded_files), 1) + self.assertEqual(skipped_count, 0) + self.assertEqual(downloaded_files[0], "/path/to/subtitle.srt") + + @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") + def test_get_subtitle_for_episode_with_tracking_skip(self, mock_interval): + """Test getting subtitles when tracking suggests skipping.""" + mock_interval.return_value = 24 + + # Mock tracker to skip searches + with patch.object( + self.downloader.tracker, "should_skip_search", return_value=True + ): + episode = { + "seriesTitle": "Breaking Bad", + "season": 1, + "episode": 1, + "missing_subtitles": [{"name": "English", "code2": "en"}], + } + + downloaded_files, skipped_count = self.downloader.get_subtitle_for_episode( + episode + ) + + self.assertEqual(len(downloaded_files), 0) + self.assertEqual(skipped_count, 1) + + @patch("api.subsource.SubSourceDownloader") + def test_download_subtitle_delegates_to_movie_downloader( + self, mock_movie_downloader_class + ): + """Test that download_subtitle delegates to movie downloader.""" + # Mock the movie downloader instance + mock_instance = Mock() + mock_instance.download_subtitle.return_value = "/path/to/downloaded.srt" + mock_movie_downloader_class.return_value = mock_instance + + subtitle = {"id": "12345", "download_url": "http://test.com/sub.zip"} + filename = "test.srt" + + result = self.downloader.download_subtitle(subtitle, filename) + + self.assertEqual(result, "/path/to/downloaded.srt") + mock_instance.download_subtitle.assert_called_once_with(subtitle, filename) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 3702a00..dd5b370 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -39,7 +39,15 @@ def test_create_default_config(self): config.read(self.config_file) # Check all required sections exist - expected_sections = ["bazarr", "auth", "subsource", "download", "logging"] + expected_sections = [ + "bazarr", + "auth", + "subsource", + "download", + "movies", + "episodes", + "logging", + ] for section in expected_sections: self.assertIn(section, config.sections()) @@ -108,6 +116,9 @@ def test_load_config_success(self, mock_home): "password", "subsource_api_url", "download_directory", + "movies_enabled", + "episodes_enabled", + "episodes_search_patterns", "log_level", "log_file", ] @@ -207,6 +218,27 @@ def test_setup_logging_rotation(self): self.assertEqual(handler.maxBytes, 10 * 1024 * 1024) # 10MB self.assertEqual(handler.backupCount, 5) + def test_episode_configuration_defaults(self): + """Test that episode configuration has proper defaults.""" + # Create basic config without episode section + config = configparser.ConfigParser() + config["bazarr"] = {"url": "http://test", "api_key": "test"} + config["auth"] = {"username": "test", "password": "test"} + config["subsource"] = {"api_url": "http://test"} + config["download"] = {"directory": "/tmp"} + config["logging"] = {"level": "INFO", "file": "test.log"} + + # Test fallback values for episodes + episodes_enabled = config.getboolean("episodes", "enabled", fallback=True) + episodes_patterns = config.get( + "episodes", + "search_patterns", + fallback="season_episode,episode_title,scene_name", + ) + + self.assertTrue(episodes_enabled) # Default should be True + self.assertEqual(episodes_patterns, "season_episode,episode_title,scene_name") + if __name__ == "__main__": unittest.main() From 547f20d25428e65324616dc05a24c30924b00ae4 Mon Sep 17 00:00:00 2001 From: MD Maksudur Rahman Khan Date: Mon, 20 Oct 2025 23:11:44 +0200 Subject: [PATCH 2/5] refactor: consolidate tv episode support and add subtitle sync features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrated TV episode functionality from separate modules into main Bazarr client - Removed api/bazarr_episodes.py and api/tv_shows.py (consolidated into api/bazarr.py) - Added automatic subtitle synchronization using Bazarr's SubSync - Added Sub-Zero subtitle modification support - Implemented configurable sync parameters (max_offset, framerate fix, GSS) - Replaced Black, isort, and flake8 with Ruff for faster, unified linting - Updated documentation with sync/Sub-Zero feature details and processing flow - Improved terminology consistency (TV shows โ†’ TV series) - Updated all tests to reflect consolidated architecture --- .pre-commit-config.yaml | 21 +- README.md | 66 +++- api/bazarr.py | 536 +++++++++++++++++++++++++++++- api/bazarr_episodes.py | 211 ------------ api/subsource.py | 400 +++++++++++++++++++++- api/tv_shows.py | 348 ------------------- core/config.py | 9 +- run.py | 404 +++++++++++++++------- tests/api/test_bazarr.py | 518 ++++++++++++++++++++++++++++- tests/api/test_bazarr_episodes.py | 210 ------------ tests/api/test_subsource.py | 133 +++++++- tests/api/test_tv_shows.py | 254 -------------- tests/test_run.py | 63 +++- utils.py | 53 ++- 14 files changed, 2018 insertions(+), 1208 deletions(-) delete mode 100644 api/bazarr_episodes.py delete mode 100644 api/tv_shows.py delete mode 100644 tests/api/test_bazarr_episodes.py delete mode 100644 tests/api/test_tv_shows.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8da16c..408c6fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,20 +10,9 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - - repo: https://github.com/psf/black - rev: 25.1.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.0 hooks: - - id: black - language_version: python3 - - - repo: https://github.com/pycqa/isort - rev: 6.0.1 - hooks: - - id: isort - args: ["--profile", "black"] - - - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - args: [--max-line-length=88, --extend-ignore=E203] + - id: ruff-check + args: [ --fix ] + - id: ruff-format diff --git a/README.md b/README.md index e3cd6c2..539e54b 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ A Python automation tool that connects to your Bazarr instance, identifies movie ## Features - ๐ŸŽฌ **Automatic Movie Detection**: Lists all movies missing subtitles from your Bazarr instance -- ๐Ÿ“บ **TV Show Episode Support**: Automatically downloads subtitles for wanted TV show episodes +- ๐Ÿ“บ **TV Series Episode Support**: Automatically downloads subtitles for wanted TV series episodes - ๐ŸŒ **SubSource Integration**: Downloads subtitles from SubSource's anonymous API (no account needed) - ๐Ÿ“ค **Seamless Upload**: Automatically uploads downloaded subtitles back to Bazarr +- ๐Ÿ”„ **Automatic Synchronization**: Built-in subtitle sync with configurable parameters (No Framerate Fix, GSS, etc.) - ๐ŸŒ **Multi-language Support**: Supports multiple languages, forced, and hearing impaired subtitles - โฑ๏ธ **Smart Retry Logic**: Uses Bazarr's own search intervals to avoid redundant API calls - ๐Ÿ“Š **Progress Tracking**: Tracks search history to prevent unnecessary duplicate searches @@ -70,11 +71,12 @@ A Python automation tool that connects to your Bazarr instance, identifies movie enabled = true [episodes] - # Enable TV show episode subtitle downloads + # Enable TV series episode subtitle downloads enabled = true # Search patterns: season_episode,episode_title,scene_name search_patterns = season_episode,episode_title,scene_name + [logging] level = INFO file = /var/log/bazarr_subsource.log @@ -159,7 +161,7 @@ The tool's built-in tracking system prevents redundant searches, making frequent - `enabled`: Enable movie subtitle downloads (default: `true`) ### Episodes Settings -- `enabled`: Enable TV show episode subtitle downloads (default: `true`) +- `enabled`: Enable TV series episode subtitle downloads (default: `true`) - `search_patterns`: Episode search patterns, comma-separated (default: `season_episode,episode_title,scene_name`) - `season_episode`: Search using "Series S01E01" format - `episode_title`: Search using "Series Episode Title" format @@ -180,7 +182,7 @@ The tool's built-in tracking system prevents redundant searches, making frequent 5. **Upload to Bazarr**: Uploads extracted subtitles back to Bazarr using the `/api/movies/subtitles` endpoint 6. **Cleanup**: Removes temporary files and updates tracking data -### TV Show Episodes +### TV Series Episodes 1. **Connect to Bazarr**: Fetches all episodes missing subtitles using the `/api/episodes/wanted` endpoint 2. **Episode Enrichment**: Retrieves series information for each episode from `/api/series` 3. **Multi-Pattern Search**: Searches SubSource using various patterns: @@ -193,6 +195,60 @@ The tool's built-in tracking system prevents redundant searches, making frequent ## Advanced Features +### Automatic Subtitle Synchronization +The tool uses Bazarr's built-in SubSync functionality for subtitle synchronization: + +#### Features +- **Conditional Sync**: Automatically synchronizes subtitles only if SubSync is enabled in Bazarr +- **Uses Bazarr Settings**: Reads sync parameters directly from your Bazarr configuration +- **Audio/Video Reference**: Uses first audio track as reference for sync +- **Framerate Handling**: Respects your Bazarr SubSync framerate settings +- **Advanced Algorithms**: Uses Golden-Section Search (GSS) if enabled in Bazarr + +#### How It Works +1. **Check SubSync Status**: Tool reads your Bazarr's SubSync settings via `/api/system/settings` +2. **Upload**: Subtitle is uploaded to Bazarr successfully +3. **Conditional Sync**: If SubSync is enabled in Bazarr, synchronization is performed +4. **Retrieve Path**: Gets the server-side subtitle file path from Bazarr +5. **Synchronize**: Calls Bazarr's `/api/subtitles` PATCH endpoint with your configured parameters +6. **Verification**: Confirms successful synchronization before cleanup + +#### SubSync Configuration +No configuration needed in this tool! SubSync settings are automatically retrieved from your Bazarr instance: +- Enable/disable SubSync in Bazarr Settings โ†’ Subtitles โ†’ SubSync +- Configure `max_offset_seconds`, `no_fix_framerate`, and `gss` settings in Bazarr +- Tool automatically uses your Bazarr SubSync preferences + +**Note**: If SubSync is disabled in Bazarr, subtitles will be uploaded without synchronization. + +### Sub-Zero Subtitle Content Modifications +The tool displays your Bazarr Sub-Zero configuration for subtitle content modifications: + +#### Features +- **Automatic Detection**: Reads Sub-Zero modification settings from Bazarr +- **Modification Display**: Shows which Sub-Zero mods are active +- **No Configuration Required**: Uses your existing Bazarr Sub-Zero settings + +#### How It Works +1. **Read Settings**: Tool fetches Sub-Zero configuration from `/api/system/settings` +2. **Display Status**: Shows whether Sub-Zero mods are enabled and which ones are active +3. **Automatic Triggering**: After successful subtitle upload, triggers Sub-Zero modifications if enabled +4. **Post-Processing**: Sub-Zero mods are applied before subtitle synchronization + +#### Common Sub-Zero Modifications +Based on your Bazarr configuration, common modifications include: +- **common**: Basic fixes (OCR errors, formatting, color tags) +- **hearing_impaired**: Hearing impaired content processing +- **ocr**: Advanced OCR error correction +- **fps**: Frame rate conversion fixes + +#### Processing Order +After successful subtitle upload to Bazarr: +1. **Sub-Zero Modifications** (if enabled): Applied first to improve subtitle quality +2. **SubSync** (if enabled): Applied second to synchronize timing + +This ensures subtitles are properly cleaned up before timing synchronization. + ### Intelligent Retry Logic The tool integrates with Bazarr's system tasks to determine optimal search intervals: - Reads Bazarr's "Search for Missing Movies Subtitles" task interval @@ -318,7 +374,7 @@ SubSource's anonymous API has rate limits. This tool implements: **Episode subtitles not found** - Episodes are searched using multiple patterns (S01E01, episode title, scene name) -- SubSource has limited TV show coverage compared to movies +- SubSource has limited TV series coverage compared to movies - Check if the series name matches exactly in both Bazarr and SubSource - Some episodes may not have subtitles available on SubSource diff --git a/api/bazarr.py b/api/bazarr.py index 6263000..9344a4a 100644 --- a/api/bazarr.py +++ b/api/bazarr.py @@ -6,7 +6,7 @@ import logging import os import re -from typing import Dict, Optional +from typing import Dict, List, Optional import requests @@ -54,7 +54,7 @@ def get_wanted_movies(self, start: int = 0, length: int = -1) -> Optional[Dict]: logger.error(f"Error parsing JSON response: {e}") return None - def upload_subtitle_to_bazarr( + def upload_movie_subtitle( self, radarr_id: int, subtitle_file: str, @@ -63,7 +63,7 @@ def upload_subtitle_to_bazarr( hi: bool = False, ) -> bool: """ - Upload a subtitle file to Bazarr. + Upload a subtitle file for a movie to Bazarr. Args: radarr_id: Radarr movie ID @@ -105,6 +105,207 @@ def upload_subtitle_to_bazarr( print(f" โœ— Error reading subtitle file: {e}") return False + def sync_subtitle( + self, + subtitle_path: str, + media_type: str, + media_id: int, + language: str, + forced: bool = False, + hi: bool = False, + reference: str = "a:0", + max_offset_seconds: int = 300, + no_fix_framerate: bool = False, + use_gss: bool = False, + ) -> bool: + """ + Synchronize a subtitle file using Bazarr's sync functionality. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + media_type: Either "movie" or "episode" + media_id: Media ID (radarrId for movies, episodeId for episodes) + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + reference: Reference for sync (e.g., 'a:0' for first audio track) + max_offset_seconds: Maximum offset seconds to allow + no_fix_framerate: Don't try to fix framerate issues + use_gss: Use Golden-Section Search algorithm + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare sync parameters + params = { + "action": "sync", + "language": language, + "path": subtitle_path, + "type": media_type, + "id": media_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + "reference": reference, + "max_offset_seconds": str(max_offset_seconds), + "no_fix_framerate": "true" if no_fix_framerate else "false", + "gss": "true" if use_gss else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=300 + ) + response.raise_for_status() + + print(" โœ“ Synchronized subtitle with Bazarr") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error synchronizing subtitle: {e}") + return False + + def trigger_subzero_mods( + self, + subtitle_path: str, + media_type: str, + media_id: int, + language: str, + forced: bool = False, + hi: bool = False, + ) -> bool: + """ + Trigger Sub-Zero subtitle modifications using Bazarr's API. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + media_type: Either "movie" or "episode" + media_id: Media ID (radarrId for movies, episodeId for episodes) + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare Sub-Zero modification parameters + params = { + "action": "subzero", + "language": language, + "path": subtitle_path, + "type": media_type, + "id": media_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=60 + ) + response.raise_for_status() + + print(" โœ“ Applied Sub-Zero modifications") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error applying Sub-Zero modifications: {e}") + return False + + def get_movie_subtitles(self, radarr_id: int) -> Optional[Dict]: + """ + Get movie details including subtitle information. + + Args: + radarr_id: Radarr movie ID + + Returns: + Movie data with subtitle paths or None if error + """ + try: + url = f"{self.bazarr_url}/api/movies" + params = {"radarrid[]": radarr_id} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + if data and "data" in data and data["data"]: + return data["data"][0] # Return first (and only) movie + return None + + except requests.exceptions.RequestException as e: + logger.error(f"Error getting movie subtitles: {e}") + return None + except (json.JSONDecodeError, KeyError, IndexError) as e: + logger.error(f"Error parsing movie subtitles response: {e}") + return None + + def get_system_settings(self) -> Optional[Dict]: + """ + Get system settings from Bazarr. + + Returns: + Dictionary containing system settings or None if error + """ + try: + url = f"{self.bazarr_url}/api/system/settings" + response = self.session.get(url, auth=self.auth, timeout=30) + response.raise_for_status() + return response.json() + except (requests.exceptions.RequestException, json.JSONDecodeError) as e: + logger.error(f"Error fetching system settings: {e}") + return None + + def get_sync_settings(self) -> Dict: + """ + Get subtitle synchronization settings from Bazarr's system settings. + + Returns: + Dictionary containing sync settings with defaults if not available + """ + settings = self.get_system_settings() + if not settings or "subsync" not in settings: + logger.warning("Could not fetch sync settings from Bazarr, using defaults") + return { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + + subsync = settings["subsync"] + return { + "enabled": subsync.get("use_subsync", False), + "max_offset_seconds": subsync.get("max_offset_seconds", 300), + "no_fix_framerate": subsync.get("no_fix_framerate", False), + "use_gss": subsync.get("gss", False), + "reference": "a:0", # Always use first audio track as reference + } + + def get_subzero_settings(self) -> Dict: + """ + Get Sub-Zero subtitle modification settings from Bazarr's system settings. + + Returns: + Dictionary containing Sub-Zero settings with defaults if not available + """ + settings = self.get_system_settings() + if not settings or "general" not in settings: + logger.warning( + "Could not fetch Sub-Zero settings from Bazarr, using defaults" + ) + return {"mods": [], "enabled": False} + + general = settings["general"] + subzero_mods = general.get("subzero_mods", []) + + return {"mods": subzero_mods, "enabled": len(subzero_mods) > 0} + def get_system_tasks(self) -> Optional[Dict]: """ Fetch system tasks from Bazarr API to get search intervals. @@ -182,7 +383,6 @@ def get_missing_subtitles_search_interval(self) -> int: search_name.lower() in task_job_id.lower() for search_name in search_task_names ): - # Get interval - could be in different formats interval_str = task.get("interval", "") @@ -295,3 +495,331 @@ def _parse_interval_to_minutes(self, interval_str: str) -> int: return int(interval_str) * 60 raise ValueError(f"Unrecognized interval format: {interval_str}") + + def get_wanted_episodes(self, start: int = 0, length: int = -1) -> List[Dict]: + """ + Get list of wanted episodes from Bazarr. + + Args: + start: Paging start integer + length: Paging length integer (-1 for all) + + Returns: + List of wanted episode dictionaries + """ + try: + url = f"{self.bazarr_url}/api/episodes/wanted" + params = {"start": start, "length": length} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + episodes = data.get("data", []) if isinstance(data, dict) else data + + # Enrich episode data with series information + enriched_episodes = [] + for episode in episodes: + enriched_episode = self._enrich_episode_data(episode) + if enriched_episode: + enriched_episodes.append(enriched_episode) + + logger.info(f"Found {len(enriched_episodes)} wanted episodes") + return enriched_episodes + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching wanted episodes: {e}") + return [] + + def _enrich_episode_data(self, episode: Dict) -> Optional[Dict]: + """ + Enrich episode data with series information. + + Args: + episode: Raw episode data from Bazarr + + Returns: + Enriched episode data or None + """ + season, episode_number = episode.get("episode_number", "").split("x") + enriched_episode = { + "series_title": episode.get("seriesTitle", "Unknown Series").strip(), + "season": season, + "episode_number": episode_number, + "episode_title": episode.get("episodeTitle", "Unknown Episode").strip(), + "missing_subtitles": episode.get("missing_subtitles", []), + "sonarr_series_id": episode.get("sonarrSeriesId", ""), + "sonarr_episode_id": episode.get("sonarrEpisodeId", ""), + "scene_name": episode.get("sceneName", ""), + "tags": episode.get("tags", []), + "series_type": episode.get("seriesType", "standard"), + } + + try: + # Get more series information + series_info = self.get_series_info(enriched_episode["sonarr_series_id"]) + if series_info: + enriched_episode["year"] = series_info.get("year") + enriched_episode["imdb"] = series_info.get("imdbId") + enriched_episode["tvdb"] = series_info.get("tvdbId") + + return enriched_episode + + except Exception as e: + logger.warning(f"Could not enrich episode data: {e}") + return enriched_episode + + def get_series_info(self, series_id: int) -> Optional[Dict]: + """ + Get series information from Bazarr. + + Args: + series_id: Series ID + + Returns: + Series information dictionary or None + """ + try: + url = f"{self.bazarr_url}/api/series" + params = {"seriesid[]": series_id} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + series_list = data.get("data", []) if isinstance(data, dict) else data + + # Find the series with matching ID + for series in series_list: + if ( + series.get("sonarrSeriesId") == series_id + or series.get("seriesId") == series_id + ): + return series + + return None + + except requests.exceptions.RequestException as e: + logger.warning(f"Error fetching series info for ID {series_id}: {e}") + return None + + def upload_episode_subtitle( + self, + series_id: int, + episode_id: int, + language: str, + subtitle_file: str, + forced: bool = False, + hi: bool = False, + ) -> bool: + """ + Upload a subtitle file for an episode to Bazarr. + + Args: + series_id: Series ID + episode_id: Episode ID + language: Language code (e.g., 'en') + subtitle_file: Path to subtitle file + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + + Returns: + True if upload successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/episodes/subtitles" + + params = { + "seriesid": series_id, + "episodeid": episode_id, + "language": language, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + } + + with open(subtitle_file, "rb") as f: + files = {"file": (subtitle_file, f, "text/plain")} + + response = self.session.post( + url, params=params, files=files, auth=self.auth, timeout=60 + ) + response.raise_for_status() + + logger.info( + f"Successfully uploaded subtitle for episode {episode_id}: " + f"{subtitle_file}" + ) + return True + + except requests.exceptions.RequestException as e: + logger.error(f"Error uploading episode subtitle: {e}") + return False + except FileNotFoundError: + logger.error(f"Subtitle file not found: {subtitle_file}") + return False + + def get_episode_search_interval(self) -> int: + """ + Get episode search interval from Bazarr settings. + + Returns: + Search interval in hours (default 24) + """ + try: + url = f"{self.bazarr_url}/api/system/settings" + response = self.session.get(url, auth=self.auth, timeout=30) + response.raise_for_status() + + settings = response.json() + + # Look for episode search interval setting + # This might be under different keys depending on Bazarr version + interval = settings.get("general", {}).get("episode_search_interval", 24) + if isinstance(interval, str): + interval = int(interval) + + return max(1, interval) # Minimum 1 hour + + except Exception as e: + logger.warning(f"Could not get episode search interval from Bazarr: {e}") + return 24 # Default fallback + + def sync_episode_subtitle( + self, + subtitle_path: str, + series_id: int, + episode_id: int, + language: str, + forced: bool = False, + hi: bool = False, + reference: str = "a:0", + max_offset_seconds: int = 300, + no_fix_framerate: bool = False, + use_gss: bool = False, + ) -> bool: + """ + Synchronize an episode subtitle file using Bazarr's sync functionality. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + series_id: Sonarr series ID + episode_id: Sonarr episode ID + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + reference: Reference for sync (e.g., 'a:0' for first audio track) + max_offset_seconds: Maximum offset seconds to allow + no_fix_framerate: Don't try to fix framerate issues + use_gss: Use Golden-Section Search algorithm + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare sync parameters + params = { + "action": "sync", + "language": language, + "path": subtitle_path, + "type": "episode", + "id": episode_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + "reference": reference, + "max_offset_seconds": str(max_offset_seconds), + "no_fix_framerate": "true" if no_fix_framerate else "false", + "gss": "true" if use_gss else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=300 + ) + response.raise_for_status() + + print(" โœ“ Synchronized episode subtitle with Bazarr") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error synchronizing episode subtitle: {e}") + return False + + def trigger_episode_subzero_mods( + self, + subtitle_path: str, + series_id: int, + episode_id: int, + language: str, + forced: bool = False, + hi: bool = False, + ) -> bool: + """ + Trigger Sub-Zero subtitle modifications for an episode using Bazarr's API. + + Args: + subtitle_path: Path to the subtitle file on the Bazarr server + series_id: Sonarr series ID + episode_id: Sonarr episode ID + language: Language code (e.g., 'en') + forced: Whether subtitle is forced + hi: Whether subtitle is hearing impaired + + Returns: + True if successful, False otherwise + """ + try: + url = f"{self.bazarr_url}/api/subtitles" + + # Prepare Sub-Zero modification parameters + params = { + "action": "subzero", + "language": language, + "path": subtitle_path, + "type": "episode", + "id": episode_id, + "forced": "true" if forced else "false", + "hi": "true" if hi else "false", + } + + response = self.session.patch( + url, params=params, auth=self.auth, timeout=60 + ) + response.raise_for_status() + + print(" โœ“ Applied Sub-Zero modifications to episode") + return True + + except requests.exceptions.RequestException as e: + print(f" โœ— Error applying Sub-Zero modifications to episode: {e}") + return False + + def get_episode_subtitles(self, series_id: int, episode_id: int) -> Optional[Dict]: + """ + Get episode details including subtitle information. + + Args: + series_id: Sonarr series ID + episode_id: Sonarr episode ID + + Returns: + Episode data with subtitle paths or None if error + """ + try: + url = f"{self.bazarr_url}/api/episodes" + params = {"seriesid[]": series_id, "episodeid[]": episode_id} + + response = self.session.get(url, params=params, auth=self.auth, timeout=30) + response.raise_for_status() + + data = response.json() + if data and "data" in data and data["data"]: + return data["data"][0] # Return first (and only) episode + return None + + except requests.exceptions.RequestException as e: + logger.error(f"Error getting episode subtitles: {e}") + return None + except (KeyError, IndexError) as e: + logger.error(f"Error parsing episode subtitles response: {e}") + return None diff --git a/api/bazarr_episodes.py b/api/bazarr_episodes.py deleted file mode 100644 index d86ddbd..0000000 --- a/api/bazarr_episodes.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Bazarr API client for TV show episodes. -""" - -import logging -from typing import Dict, List, Optional - -import requests - -logger = logging.getLogger(__name__) - - -class BazarrEpisodeClient: - """Bazarr API client for episode operations.""" - - def __init__(self, base_url: str, api_key: str, username: str, password: str): - self.base_url = base_url.rstrip("/") - self.api_key = api_key - self.session = requests.Session() - - # Set up authentication - if username and password: - self.session.auth = (username, password) - - self.session.headers.update( - { - "X-API-KEY": api_key, - "Content-Type": "application/json", - } - ) - - def get_wanted_episodes(self, start: int = 0, length: int = -1) -> List[Dict]: - """ - Get list of wanted episodes from Bazarr. - - Args: - start: Paging start integer - length: Paging length integer (-1 for all) - - Returns: - List of wanted episode dictionaries - """ - try: - url = f"{self.base_url}/api/episodes/wanted" - params = {"start": start, "length": length} - - response = self.session.get(url, params=params, timeout=30) - response.raise_for_status() - - data = response.json() - episodes = data.get("data", []) if isinstance(data, dict) else data - - # Enrich episode data with series information - enriched_episodes = [] - for episode in episodes: - enriched_episode = self._enrich_episode_data(episode) - if enriched_episode: - enriched_episodes.append(enriched_episode) - - logger.info(f"Found {len(enriched_episodes)} wanted episodes") - return enriched_episodes - - except requests.exceptions.RequestException as e: - logger.error(f"Error fetching wanted episodes: {e}") - return [] - - def _enrich_episode_data(self, episode: Dict) -> Optional[Dict]: - """ - Enrich episode data with additional series information. - - Args: - episode: Raw episode data from Bazarr - - Returns: - Enriched episode data or None if series info unavailable - """ - series_id = episode.get("sonarrSeriesId") or episode.get("seriesId") - if not series_id: - logger.warning( - f"No series ID found for episode: {episode.get('title', 'Unknown')}" - ) - return episode - - try: - # Get series information - series_info = self.get_series_info(series_id) - if series_info: - episode["seriesTitle"] = series_info.get("title", "Unknown Series") - episode["seriesYear"] = series_info.get("year") - episode["seriesImdb"] = series_info.get("imdbId") - - return episode - - except Exception as e: - logger.warning(f"Could not enrich episode data: {e}") - return episode - - def get_series_info(self, series_id: int) -> Optional[Dict]: - """ - Get series information from Bazarr. - - Args: - series_id: Series ID - - Returns: - Series information dictionary or None - """ - try: - url = f"{self.base_url}/api/series" - params = {"seriesid[]": series_id} - - response = self.session.get(url, params=params, timeout=30) - response.raise_for_status() - - data = response.json() - series_list = data.get("data", []) if isinstance(data, dict) else data - - # Find the series with matching ID - for series in series_list: - if ( - series.get("sonarrSeriesId") == series_id - or series.get("seriesId") == series_id - ): - return series - - return None - - except requests.exceptions.RequestException as e: - logger.warning(f"Error fetching series info for ID {series_id}: {e}") - return None - - def upload_episode_subtitle( - self, - series_id: int, - episode_id: int, - language: str, - subtitle_file: str, - forced: bool = False, - hi: bool = False, - ) -> bool: - """ - Upload a subtitle file for an episode to Bazarr. - - Args: - series_id: Series ID - episode_id: Episode ID - language: Language code (e.g., 'en') - subtitle_file: Path to subtitle file - forced: Whether subtitle is forced - hi: Whether subtitle is hearing impaired - - Returns: - True if upload successful, False otherwise - """ - try: - url = f"{self.base_url}/api/episodes/subtitles" - - params = { - "seriesid": series_id, - "episodeid": episode_id, - "language": language, - "forced": "true" if forced else "false", - "hi": "true" if hi else "false", - } - - with open(subtitle_file, "rb") as f: - files = {"file": (subtitle_file, f, "text/plain")} - - response = self.session.post( - url, params=params, files=files, timeout=60 - ) - response.raise_for_status() - - logger.info( - f"Successfully uploaded subtitle for episode {episode_id}: " - f"{subtitle_file}" - ) - return True - - except requests.exceptions.RequestException as e: - logger.error(f"Error uploading episode subtitle: {e}") - return False - except FileNotFoundError: - logger.error(f"Subtitle file not found: {subtitle_file}") - return False - - def get_search_interval(self) -> int: - """ - Get search interval from Bazarr settings. - - Returns: - Search interval in hours (default 24) - """ - try: - url = f"{self.base_url}/api/system/settings" - response = self.session.get(url, timeout=30) - response.raise_for_status() - - settings = response.json() - - # Look for episode search interval setting - # This might be under different keys depending on Bazarr version - interval = settings.get("general", {}).get("episode_search_interval", 24) - if isinstance(interval, str): - interval = int(interval) - - return max(1, interval) # Minimum 1 hour - - except Exception as e: - logger.warning(f"Could not get search interval from Bazarr: {e}") - return 24 # Default fallback diff --git a/api/subsource.py b/api/subsource.py index 963e457..5bd4e17 100644 --- a/api/subsource.py +++ b/api/subsource.py @@ -5,9 +5,10 @@ import json import logging import os +import re import time import zipfile -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple import requests @@ -31,13 +32,12 @@ def __init__(self, api_url: str, download_dir: str, bazarr=None): # Setup optimized session headers self.session.headers.update( { - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36" - ), "Accept": "application/json", "Content-Type": "application/json", "Connection": "keep-alive", + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + ), } ) @@ -207,8 +207,7 @@ def download_subtitle(self, subtitle_info: Dict, filename: str) -> Optional[str] if not download_token: logger.error( - f"No download token found in response for subtitle ID " - f"{subtitle_id}" + f"No download token found in response for subtitle ID {subtitle_id}" ) logger.debug(f"Response data: {details_data}") return None @@ -234,8 +233,7 @@ def download_subtitle(self, subtitle_info: Dict, filename: str) -> Optional[str] if "text/html" in content_type: logger.error( - f"Received HTML instead of ZIP file for subtitle ID " - f"{subtitle_id}" + f"Received HTML instead of ZIP file for subtitle ID {subtitle_id}" ) return None @@ -317,8 +315,7 @@ def _extract_subtitle_from_zip( if not subtitle_files: logger.error( - f"No subtitle files found in ZIP for subtitle ID " - f"{subtitle_id}" + f"No subtitle files found in ZIP for subtitle ID {subtitle_id}" ) return None @@ -562,3 +559,384 @@ def get_tracking_summary(self) -> dict: summary = self.tracker.get_tracking_summary() summary["search_interval_hours"] = self._get_search_interval_hours() return summary + + # TV Series / Episode methods + + def _extract_episode_info_from_subtitle( + self, subtitle: Dict + ) -> Tuple[Optional[int], Optional[int]]: + """ + Extract season/episode info from subtitle release info. + + Args: + subtitle: Subtitle data from SubSource + + Returns: + Tuple of (season, episode) or (None, None) if not found + """ + release_info = subtitle.get("release_info", "") + + # Look for E01 pattern (simplified) + episode_match = re.search(r"[Ee](\d+)", release_info) + if episode_match: + episode = int(episode_match.group(1)) + # For E01 format, we don't extract season from the subtitle + # We rely on the season context from the search + return None, episode + + # Fallback: Look for S01E01 pattern for compatibility + season_episode_match = re.search(r"[Ss](\d+)[Ee](\d+)", release_info) + if season_episode_match: + season = int(season_episode_match.group(1)) + episode = int(season_episode_match.group(2)) + return season, episode + + # Look for 1x01 pattern + alt_pattern = re.search(r"(\d+)x(\d+)", release_info) + if alt_pattern: + season = int(alt_pattern.group(1)) + episode = int(alt_pattern.group(2)) + return season, episode + + return None, None + + def _find_best_series_match( + self, + search_results: List[Dict], + series_title: str, + series_year: Optional[int], + season: int, + ) -> Optional[Dict]: + """ + Find the best matching TV series from search results. + + Args: + search_results: List of search results from SubSource + series_title: Target series title + series_year: Target series year (optional) + season: Target season number + + Returns: + Best matching series or None + """ + series_candidates = [] + + # Filter for TV series results + for result in search_results: + result_type = result.get("type", "").lower() + result_title = result.get("title", "").lower() + series_title_lower = series_title.lower() + + # Must be a TV series and title must match + if result_type == "tvseries" and series_title_lower in result_title: + series_candidates.append(result) + + if not series_candidates: + print(f" No TV series found matching '{series_title}'") + return None + + print(f" Found {len(series_candidates)} TV series candidate(s)") + + # If only one candidate, return it + if len(series_candidates) == 1: + return series_candidates[0] + + # Multiple candidates - filter by release year if available + if series_year: + year_matches = [] + for candidate in series_candidates: + candidate_year = candidate.get("releaseYear") + if candidate_year == series_year: + year_matches.append(candidate) + + if year_matches: + print(f" Matched {len(year_matches)} series by year {series_year}") + # If multiple year matches, check for season availability + if len(year_matches) == 1: + return year_matches[0] + else: + # Check which has the target season + for candidate in year_matches: + if self._has_season(candidate, season): + return candidate + # If no season match, return first year match + return year_matches[0] + + # No year match or no year provided - check for season availability + for candidate in series_candidates: + if self._has_season(candidate, season): + print(f" Selected series with season {season}") + return candidate + + # If no season match, return first candidate + print(" No exact match found, using first candidate") + return series_candidates[0] + + def _has_season(self, series: Dict, season: int) -> bool: + """ + Check if a series has the specified season. + + Args: + series: Series data from SubSource + season: Target season number + + Returns: + True if series has the season + """ + seasons = series.get("seasons", []) + if not seasons: + return False + + for season_data in seasons: + season_num = season_data.get("season") + if season_num == season: + return True + return False + + def _get_season_link(self, series: Dict, season: int) -> Optional[str]: + """ + Get the link for a specific season from series data. + + Args: + series: Series data from SubSource + season: Target season number + + Returns: + Season link or None if not found + """ + seasons = series.get("seasons", []) + if not seasons: + print(" No seasons data available") + return None + + # Look for exact season match first + for season_data in seasons: + season_num = int(season_data.get("season")) + if int(season_num) == int(season): + link = season_data.get("link") + if link: + print(f" Found exact season {season} match") + return link.replace("=", "-") + + # If only one season available, use it even if number doesn't match + if len(seasons) == 1: + link = seasons[0].get("link") + if link: + available_season = seasons[0].get("season", "unknown") + print( + f" Using only available season {available_season} for target season {season}" + ) + return link.replace("=", "-") + + # Multiple seasons but no exact match - check release years + series_year = series.get("releaseYear") + if series_year: + # Try to find season with matching release year or close to it + for season_data in seasons: + season_year = season_data.get("releaseYear") + if season_year and abs(season_year - series_year) <= 1: # Within 1 year + link = season_data.get("link") + if link: + available_season = season_data.get("season", "unknown") + print( + f" Using season {available_season} based on release year match" + ) + return link.replace("=", "-") + + print(f" No suitable season found for season {season}") + return None + + def _is_subtitle_match(self, subtitle: Dict, target_episode: Dict) -> bool: + """ + Check if a subtitle matches the target episode. + + Args: + subtitle: Subtitle data from SubSource + target_episode: Episode data from Bazarr + + Returns: + True if subtitle matches episode + """ + target_season = target_episode.get("season") + target_episode_num = target_episode.get("episode") + + if not target_season or not target_episode_num: + return False + + sub_season, sub_episode = self._extract_episode_info_from_subtitle(subtitle) + + # If we got both season and episode from subtitle (S01E01 format) + if sub_season is not None and sub_episode is not None: + return sub_season == target_season and sub_episode == target_episode_num + + # If we only got episode number (E01 format), match just the episode + # We assume the season context is correct from the search + if sub_episode is not None: + return sub_episode == target_episode_num + + return False + + def search_episode_subtitles( + self, episode: Dict, language: str = "english" + ) -> List[Dict]: + """ + Search for subtitles for a specific episode. + + Args: + episode: Episode data from Bazarr + language: Subtitle language + + Returns: + List of matching subtitle results + """ + series_title = episode.get("series_title", "Unknown") + season = episode.get("season", 0) + episode_number = episode.get("episode_number", 0) + series_year = episode.get("seriesYear") + + print(f" Searching SubSource for: {series_title} S{season}E{episode_number}") + + try: + print(f" Searching with series name: {series_title}") + + # Search with original series name only + search_url = f"{self.api_url}/movie/search" + search_payload = { + "query": series_title, + "signal": {}, + "includeSeasons": True, # Include TV shows + "limit": 15, + } + + response = self.session.post(search_url, json=search_payload, timeout=15) + response.raise_for_status() + + time.sleep(2) # Rate limiting + + search_data = response.json() + search_results = search_data.get("results", []) + + print(f" Found {len(search_results)} result(s)") + + # Find the best matching TV series + best_series = self._find_best_series_match( + search_results, series_title, series_year, season + ) + + if not best_series: + print(" No matching TV series found") + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + return [] + + # Get season link from the best series + season_link = self._get_season_link(best_series, season) + if not season_link: + print(f" Season {season} not found in series") + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + return [] + + print(f" Found season {season}, getting subtitles...") + + # Get subtitles for the season + subtitles_url = f"{self.api_url}{season_link}" + params = {"language": language.lower(), "sort_by_date": "false"} + + time.sleep(2) # Rate limiting + + sub_response = self.session.get(subtitles_url, params=params, timeout=15) + sub_response.raise_for_status() + + subtitles_data = sub_response.json() + if isinstance(subtitles_data, list): + subtitles = subtitles_data + else: + subtitles = subtitles_data.get("subtitles", []) + + # Filter subtitles that match our episode + matching_subtitles = [] + for subtitle in subtitles: + if self._is_subtitle_match(subtitle, episode): + subtitle["source_query"] = series_title + subtitle["source_link"] = season_link + matching_subtitles.append(subtitle) + + print(f" Found {len(matching_subtitles)} matching episode subtitles") + + if not matching_subtitles: + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + + return matching_subtitles + + except requests.exceptions.RequestException as e: + print(f" Error searching for episode: {e}") + episode_key = f"{series_title}:S{season}E{episode_number}" + self.tracker.record_no_subtitles_found(episode_key, 0, language) + return [] + + def get_subtitle_for_episode(self, episode: Dict) -> Tuple[List[str], int]: + """ + Download subtitles for an episode. + + Args: + episode: Episode dictionary from Bazarr API + + Returns: + Tuple of (downloaded subtitle file paths, number of skipped subtitles) + """ + series_title = episode.get("series_title") + season = episode.get("season") + episode_number = episode.get("episode_number") + missing_subs = episode.get("missing_subtitles", []) + + episode_key = f"{series_title}:S{season}E{episode_number}" + + downloaded_files = [] + skipped_count = 0 + + print(f" Processing: {episode_key}") + + for sub in missing_subs: + lang_name = sub.get("name", "Unknown") + lang_code = sub.get("code2", "en") + + print(f" Looking for {lang_name} subtitle...") + + # Check if we should skip this search based on recent failures + search_interval = self._get_search_interval_hours() + if self.tracker.should_skip_search( + episode_key, 0, lang_name.lower(), search_interval + ): + print( + f" Skipping {lang_name} subtitle " + f"(last tried within {search_interval}h interval)" + ) + skipped_count += 1 + continue + + # Search for subtitles + results = self.search_episode_subtitles(episode, lang_name.lower()) + + if not results: + print(f" No subtitles found for {lang_name}") + continue + + # Take the best result (first one) + best_result = results[0] + + # Download subtitle + downloaded_file = self.download_subtitle( + best_result, f"temp_episode_{lang_code}.srt" + ) + if downloaded_file: + downloaded_files.append(downloaded_file) + print(f" โœ“ Downloaded {lang_name} subtitle") + else: + self.tracker.record_download_failure( + episode_key, 0, lang_name.lower(), "Download failed" + ) + print(f" โœ— Failed to download {lang_name} subtitle") + + return downloaded_files, skipped_count diff --git a/api/tv_shows.py b/api/tv_shows.py deleted file mode 100644 index 35c4ae0..0000000 --- a/api/tv_shows.py +++ /dev/null @@ -1,348 +0,0 @@ -""" -TV Show SubSource API client for downloading episode subtitles. -""" - -import logging -import re -import time -from typing import Dict, List, Optional, Tuple - -import requests - -from core.tracking import SubtitleTracker - -logger = logging.getLogger(__name__) - - -class TVShowSubSourceDownloader: - """SubSource TV show subtitle downloader.""" - - def __init__(self, api_url: str, download_dir: str, bazarr=None): - self.api_url = api_url - self.download_dir = download_dir - self.session = requests.Session() - self.tracker = SubtitleTracker() - self.bazarr = bazarr - self._search_interval_hours = None - - def _get_search_interval_hours(self) -> int: - """Get search interval from Bazarr or use default.""" - if self._search_interval_hours is not None: - return self._search_interval_hours - - # Try to get from Bazarr API if available - if self.bazarr: - try: - search_interval = self.bazarr.get_search_interval() - self._search_interval_hours = search_interval - return search_interval - except Exception as e: - logger.warning(f"Could not get search interval from Bazarr: {e}") - - # Default fallback - self._search_interval_hours = 24 - return self._search_interval_hours - - def _generate_episode_search_queries(self, episode: Dict) -> List[str]: - """ - Generate search queries for an episode. - - Args: - episode: Episode data from Bazarr - - Returns: - List of search query strings - """ - series_title = episode.get("seriesTitle", "") - episode_title = episode.get("title", "") - season = episode.get("season", 0) - episode_num = episode.get("episode", 0) - scene_name = episode.get("sceneName", "") - - queries = [] - - if series_title: - # Primary: Series + S01E01 format - if season and episode_num: - queries.append(f"{series_title} S{season:02d}E{episode_num:02d}") - - # Secondary: Series + episode title - if episode_title: - queries.append(f"{series_title} {episode_title}") - - # Tertiary: Just series name (less specific) - queries.append(series_title) - - # Quaternary: Scene name if available - if scene_name: - # Extract show name from scene name - scene_clean = re.sub(r"[.\-_]", " ", scene_name) - queries.append(scene_clean) - - return queries - - def _extract_episode_info_from_subtitle( - self, subtitle: Dict - ) -> Tuple[Optional[int], Optional[int]]: - """ - Extract season/episode info from subtitle release info. - - Args: - subtitle: Subtitle data from SubSource - - Returns: - Tuple of (season, episode) or (None, None) if not found - """ - release_info = subtitle.get("release_info", "") - - # Look for S01E01 pattern - season_episode_match = re.search(r"[Ss](\d+)[Ee](\d+)", release_info) - if season_episode_match: - season = int(season_episode_match.group(1)) - episode = int(season_episode_match.group(2)) - return season, episode - - # Look for 1x01 pattern - alt_pattern = re.search(r"(\d+)x(\d+)", release_info) - if alt_pattern: - season = int(alt_pattern.group(1)) - episode = int(alt_pattern.group(2)) - return season, episode - - return None, None - - def _is_subtitle_match(self, subtitle: Dict, target_episode: Dict) -> bool: - """ - Check if a subtitle matches the target episode. - - Args: - subtitle: Subtitle data from SubSource - target_episode: Episode data from Bazarr - - Returns: - True if subtitle matches episode - """ - target_season = target_episode.get("season") - target_episode_num = target_episode.get("episode") - - if not target_season or not target_episode_num: - return False - - sub_season, sub_episode = self._extract_episode_info_from_subtitle(subtitle) - - if sub_season == target_season and sub_episode == target_episode_num: - return True - - return False - - def search_episode_subtitles( - self, episode: Dict, language: str = "english" - ) -> List[Dict]: - """ - Search for subtitles for a specific episode. - - Args: - episode: Episode data from Bazarr - language: Subtitle language - - Returns: - List of matching subtitle results - """ - series_title = episode.get("seriesTitle", "Unknown") - season = episode.get("season", 0) - episode_num = episode.get("episode", 0) - - print( - f" Searching SubSource for: {series_title} " - f"S{season:02d}E{episode_num:02d}" - ) - - queries = self._generate_episode_search_queries(episode) - all_results = [] - - for query in queries: - try: - print(f" Trying query: {query}") - - # Use movie search with includeSeasons=True to get TV content - search_url = f"{self.api_url}/movie/search" - search_payload = { - "query": query, - "signal": {}, - "includeSeasons": True, # Include TV shows - "limit": 15, - } - - response = self.session.post( - search_url, json=search_payload, timeout=15 - ) - response.raise_for_status() - - time.sleep(2) # Rate limiting - - search_data = response.json() - search_results = search_data.get("results", []) - - print(f" Found {len(search_results)} result(s)") - - # Look for both TV series and movies that might match - for result in search_results: - result_title = result.get("title", "").lower() - series_title_lower = series_title.lower() - - # Skip if title doesn't match series - if series_title_lower not in result_title: - continue - - link = result.get("link", "") - if not link: - continue - - # For TV series, we can't directly access episodes - # For movies/specials, we can access subtitles - if "/subtitles/" in link: - # This is a movie/special - get subtitles directly - subtitles_url = f"{self.api_url}{link}" - params = {"language": language.lower(), "sort_by_date": "false"} - - time.sleep(2) # Rate limiting - - sub_response = self.session.get( - subtitles_url, params=params, timeout=15 - ) - sub_response.raise_for_status() - - subtitles_data = sub_response.json() - if isinstance(subtitles_data, list): - subtitles = subtitles_data - else: - subtitles = subtitles_data.get("subtitles", []) - - # Filter subtitles that match our episode - for subtitle in subtitles: - if self._is_subtitle_match(subtitle, episode): - subtitle["source_query"] = query - subtitle["source_link"] = link - all_results.append(subtitle) - - matching_count = len( - [ - s - for s in subtitles - if self._is_subtitle_match(s, episode) - ] - ) - print( - f" Found {matching_count} matching episode subtitles" - ) - - # For TV series links, we would need a different approach - # but SubSource doesn't provide episode-level access - - except requests.exceptions.RequestException as e: - print(f" Error searching with query '{query}': {e}") - continue - - # Remove duplicates based on subtitle ID - unique_results = [] - seen_ids = set() - for result in all_results: - sub_id = result.get("id") - if sub_id and sub_id not in seen_ids: - seen_ids.add(sub_id) - unique_results.append(result) - - print(f" Found {len(unique_results)} unique matching subtitles") - - if not unique_results: - # Record failure for tracking - episode_key = f"{series_title}:S{season:02d}E{episode_num:02d}" - self.tracker.record_no_subtitles_found(episode_key, 0, language) - - return unique_results - - def download_subtitle(self, subtitle: Dict, filename: str) -> Optional[str]: - """ - Download a subtitle file. - - Args: - subtitle: Subtitle data from SubSource - filename: Local filename for the subtitle - - Returns: - Path to downloaded file or None if failed - """ - # Reuse the movie downloader logic from subsource.py - # This is the same download process - from api.subsource import SubSourceDownloader - - # Create a temporary movie downloader to reuse download logic - temp_downloader = SubSourceDownloader( - self.api_url, self.download_dir, self.bazarr - ) - return temp_downloader.download_subtitle(subtitle, filename) - - def get_subtitle_for_episode(self, episode: Dict) -> Tuple[List[str], int]: - """ - Download subtitles for an episode. - - Args: - episode: Episode dictionary from Bazarr API - - Returns: - Tuple of (downloaded subtitle file paths, number of skipped subtitles) - """ - series_title = episode.get("seriesTitle", "Unknown") - season = episode.get("season", 0) - episode_num = episode.get("episode", 0) - missing_subs = episode.get("missing_subtitles", []) - - episode_key = f"{series_title}:S{season:02d}E{episode_num:02d}" - - downloaded_files = [] - skipped_count = 0 - - print(f" Processing: {episode_key}") - - for sub in missing_subs: - lang_name = sub.get("name", "Unknown") - lang_code = sub.get("code2", "en") - - print(f" Looking for {lang_name} subtitle...") - - # Check if we should skip this search based on recent failures - search_interval = self._get_search_interval_hours() - if self.tracker.should_skip_search( - episode_key, 0, lang_name.lower(), search_interval - ): - print( - f" Skipping {lang_name} subtitle " - f"(last tried within {search_interval}h interval)" - ) - skipped_count += 1 - continue - - # Search for subtitles - results = self.search_episode_subtitles(episode, lang_name.lower()) - - if not results: - print(f" No subtitles found for {lang_name}") - continue - - # Take the best result (first one) - best_result = results[0] - - # Download subtitle - downloaded_file = self.download_subtitle( - best_result, f"temp_episode_{lang_code}.srt" - ) - if downloaded_file: - downloaded_files.append(downloaded_file) - print(f" โœ“ Downloaded {lang_name} subtitle") - else: - self.tracker.record_download_failure( - episode_key, 0, lang_name.lower(), "Download failed" - ) - print(f" โœ— Failed to download {lang_name} subtitle") - - return downloaded_files, skipped_count diff --git a/core/config.py b/core/config.py index 5d0bc72..0e73b28 100644 --- a/core/config.py +++ b/core/config.py @@ -104,7 +104,7 @@ def create_default_config(config_file: Path): "in front of Bazarr\n" ) f.write( - "# Leave empty or remove this section if connecting " "directly to Bazarr\n" + "# Leave empty or remove this section if connecting directly to Bazarr\n" ) f.write("username = your_username\n") f.write("password = your_password\n\n") @@ -121,7 +121,7 @@ def create_default_config(config_file: Path): f.write("enabled = true\n\n") f.write("[episodes]\n") - f.write("# Enable TV show episode subtitle downloads\n") + f.write("# Enable TV series episode subtitle downloads\n") f.write("enabled = true\n") f.write("# Search patterns: season_episode,episode_title,scene_name\n") f.write("search_patterns = season_episode,episode_title,scene_name\n\n") @@ -152,7 +152,10 @@ def setup_logging(log_level: str, log_file: str): # Rotating file handler - 10MB max, keep 5 old files file_handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" # 10MB + log_file, + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", # 10MB ) file_handler.setLevel(level) file_handler.setFormatter(detailed_formatter) diff --git a/run.py b/run.py index af22b4d..94bf0c9 100644 --- a/run.py +++ b/run.py @@ -36,7 +36,7 @@ from api.bazarr import Bazarr from api.subsource import SubSourceDownloader from core.config import load_config, setup_logging -from utils import format_movie_info +from utils import format_movie_info, format_episode_info # Logging will be configured after loading config logger = None @@ -49,6 +49,8 @@ def main(): try: # Load configuration first config = load_config() + movies_enabled = config.get("movies_enabled", True) + episodes_enabled = config.get("episodes_enabled", True) # Setup logging setup_logging(config["log_level"], config["log_file"]) @@ -59,11 +61,12 @@ def main(): logger.info("Starting Bazarr SubSource execution") logger.info("=" * 60) - print("Bazarr Wanted Movies - SubSource Downloader") + print("Bazarr SubSource Integration Tool") print("=" * 50) - print(f"Connecting to Bazarr at: {config['bazarr_url']}") - print("Fetching wanted movies...", end=" ", flush=True) + print( + f"Connecting to Bazarr at: {config['bazarr_url']}...", end=" ", flush=True + ) # Initialize Bazarr client bazarr = Bazarr( @@ -73,9 +76,78 @@ def main(): config["password"], ) + # Test connection and get settings + try: + sync_settings = bazarr.get_sync_settings() + subzero_settings = bazarr.get_subzero_settings() + print("โœ“ Connected") + except Exception as e: + print("โœ— Connection failed") + logger.error(f"Failed to connect to Bazarr: {e}") + raise + + print("\nBazarr Configuration:") + print("-" * 20) + + # SubSync settings + if sync_settings["enabled"]: + print("โœ“ SubSync: Enabled") + print(f" โ€ข Max Offset Seconds: {sync_settings['max_offset_seconds']}s") + print( + f" โ€ข Golden-Section Search: {'Yes' if sync_settings['use_gss'] else 'No'}" + ) + print( + f" โ€ข No Fix Framerate: {'Yes' if sync_settings['no_fix_framerate'] else 'No'}" + ) + else: + print("โœ— SubSync: Disabled") + + # Sub-Zero settings + if subzero_settings["enabled"]: + print("โœ“ Sub-Zero: Enabled") + print( + f" โ€ข Common Fixes: {'Yes' if 'common' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข Remove Tags: {'Yes' if 'remove_tags' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข OCR Fixes: {'Yes' if 'OCR_fixes' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข Fix Uppercase: {'Yes' if 'fix_uppercase' in subzero_settings['mods'] else 'No'}" + ) + print( + f" โ€ข Remove HI: {'Yes' if 'remove_HI' in subzero_settings['mods'] else 'No'}" + ) + else: + print("โœ— Sub-Zero: Disabled") + + # Initialize SubSource downloader (needed for both movies and episodes) + downloader = None + if movies_enabled or episodes_enabled: + print("\nInitializing SubSource downloader...") + downloader = SubSourceDownloader( + config["subsource_api_url"], + config["download_directory"], + bazarr, # Pass Bazarr client for API calls + ) + print(f"Download directory: {config['download_directory']}") + print("โœ“ SubSource downloader initialized") + # Process movies if enabled movies = [] - if config.get("movies_enabled", True): + total_downloads = 0 + successful_uploads = 0 + subtitles_skipped = 0 + + print("\n" + "=" * 50) + print("PROCESSING MOVIES") + print("=" * 50) + + if movies_enabled: + print("Fetching wanted movies from Bazarr...", end=" ", flush=True) + # Fetch wanted movies data = bazarr.get_wanted_movies() if data is None: @@ -94,46 +166,27 @@ def main(): # Continue with movie processing if we have movies if movies: print("\nWanted Movies:") - print("-" * 40) # Display each movie for movie in movies: print(format_movie_info(movie)) - print(f"\nTotal: {len(movies)} movies need subtitles") - - # Automatically proceed with downloading subtitles - print("\n" + "=" * 50) - print("Automatically downloading missing subtitles from SubSource...") - - # Initialize SubSource downloader - print("\nInitializing SubSource downloader...") - downloader = SubSourceDownloader( - config["subsource_api_url"], - config["download_directory"], - bazarr, # Pass Bazarr client for API calls - ) + print(f"\nTotal: {len(movies)} movies need subtitles\n") - print(f"Download directory: {config['download_directory']}") + # Movie subtitle downloads + print("Downloading missing movie subtitles:") + print("-" * 40) # Clean up obsolete tracking entries - print("Cleaning up obsolete tracking entries...") + print("Cleaning up obsolete movie tracking entries...") removed_count = downloader.tracker.cleanup_obsolete_movies(movies) if removed_count > 0: print( f"Removed {removed_count} obsolete movie(s) from tracking database" ) - print("\nStarting subtitle downloads...") - print("=" * 50) - - # Initialize counters (outside if block for summary) - total_downloads = 0 - successful_uploads = 0 - subtitles_skipped = 0 + print("\nStarting movie subtitle downloads...") - # Process movies if we have them - if movies: # Process each movie for i, movie in enumerate(movies, 1): print(f"\n[{i}/{len(movies)}] Processing movie:") @@ -167,11 +220,62 @@ def main(): forced = sub_info.get("forced", False) hi = sub_info.get("hi", False) - if bazarr.upload_subtitle_to_bazarr( + if bazarr.upload_movie_subtitle( radarr_id, subtitle_file, lang_code, forced, hi ): successful_uploads += 1 + # Get movie details to find subtitle path for post-processing + movie_data = bazarr.get_movie_subtitles(radarr_id) + if movie_data and "subtitles" in movie_data: + # Find the subtitle we just uploaded + for subtitle in movie_data["subtitles"]: + if ( + subtitle.get("code2") == lang_code + and subtitle.get("forced") == forced + and subtitle.get("hi") == hi + ): + subtitle_path = subtitle.get("path") + if subtitle_path: + # Apply Sub-Zero modifications if enabled + if subzero_settings["enabled"]: + print( + " Applying Sub-Zero modifications..." + ) + bazarr.trigger_subzero_mods( + subtitle_path=subtitle_path, + media_type="movie", + media_id=radarr_id, + language=lang_code, + forced=forced, + hi=hi, + ) + + # Perform subtitle synchronization if enabled + if sync_settings["enabled"]: + print( + " Performing subtitle synchronization..." + ) + bazarr.sync_subtitle( + subtitle_path=subtitle_path, + media_type="movie", + media_id=radarr_id, + language=lang_code, + forced=forced, + hi=hi, + reference=sync_settings[ + "reference" + ], + max_offset_seconds=sync_settings[ + "max_offset_seconds" + ], + no_fix_framerate=sync_settings[ + "no_fix_framerate" + ], + use_gss=sync_settings["use_gss"], + ) + break + # Clean up tracking database for successful download title = movie.get("title", "Unknown") year = movie.get("year", 0) @@ -191,120 +295,162 @@ def main(): if i < len(movies): time.sleep(1) - # Process TV show episodes if enabled + # Process TV series episodes if enabled + episodes = [] episodes_processed = 0 episodes_downloads = 0 episodes_uploads = 0 episodes_skipped = 0 - if config.get("episodes_enabled", True): + if episodes_enabled: print("\n" + "=" * 50) - print("PROCESSING TV SHOW EPISODES") + print("PROCESSING TV SERIES") print("=" * 50) + print("Fetching wanted episodes from Bazarr...", end=" ", flush=True) - from api.bazarr_episodes import BazarrEpisodeClient - from api.tv_shows import TVShowSubSourceDownloader + # Fetch wanted episodes + episodes = bazarr.get_wanted_episodes() + print(f"Done!\nFound {len(episodes)} wanted episodes") + else: + print("TV Series processing disabled in configuration.") - # Initialize episode clients - episode_client = BazarrEpisodeClient( - config["bazarr_url"], - config["api_key"], - config["username"], - config["password"], - ) + # Continue with tv series processing if we have tv series + if episodes: + print("\nWanted Episodes:") - tv_downloader = TVShowSubSourceDownloader( - config["subsource_api_url"], - config["download_directory"], - episode_client, - ) + # Display each episode + for episode in episodes: + print(format_episode_info(episode)) - # Get wanted episodes - episodes = episode_client.get_wanted_episodes() - episodes_processed = len(episodes) - - if not episodes: - print("No episodes want subtitles.") - else: - print(f"Found {len(episodes)} episode(s) wanting subtitles") - - # Clean up obsolete episode tracking entries - print("Cleaning up obsolete episode tracking entries...") - removed_count = tv_downloader.tracker.cleanup_obsolete_movies(episodes) - if removed_count > 0: - print( - f"Removed {removed_count} obsolete episode(s) " - f"from tracking database" - ) + print(f"\nTotal: {len(episodes)} episodes need subtitles\n") + + # TV series subtitle downloads + print("Downloading missing TV series subtitles:") + print("-" * 40) + + # Clean up obsolete episode tracking entries + print("Cleaning up obsolete episode tracking entries...") + removed_count = downloader.tracker.cleanup_obsolete_movies(episodes) + if removed_count > 0: + print( + f"Removed {removed_count} obsolete episode(s) " + f"from tracking database" + ) + + print("\nStarting episode subtitle downloads...") - for i, episode in enumerate(episodes, 1): - print(f"\n[{i}/{len(episodes)}] Processing episode...") + for i, episode in enumerate(episodes, 1): + print(f"\n[{i}/{len(episodes)}]", end=" ") + + # Download subtitles for this episode + downloaded_files, skipped_count = downloader.get_subtitle_for_episode( + episode + ) + episodes_downloads += len(downloaded_files) + episodes_skipped += skipped_count - # Download subtitles for this episode - downloaded_files, skipped_count = ( - tv_downloader.get_subtitle_for_episode(episode) + # Upload each downloaded subtitle to Bazarr + for subtitle_file in downloaded_files: + # Extract subtitle info from the episode and file + series_id = episode.get("sonarrSeriesId") or episode.get("seriesId") + episode_id = episode.get("sonarrEpisodeId") or episode.get( + "episodeId" ) - episodes_downloads += len(downloaded_files) - episodes_skipped += skipped_count - - # Upload each downloaded subtitle to Bazarr - for subtitle_file in downloaded_files: - # Extract subtitle info from the episode and file - series_id = episode.get("sonarrSeriesId") or episode.get( - "seriesId" - ) - episode_id = episode.get("sonarrEpisodeId") or episode.get( - "episodeId" - ) - - # Determine language from filename or default to first - # missing subtitle - missing_subs = episode.get("missing_subtitles", []) - if missing_subs: - lang_code = missing_subs[0].get("code2", "en") - lang_name = missing_subs[0].get("name", "Unknown") - else: - lang_code = "en" - lang_name = "English" - - if series_id and episode_id: - sub_info = {"name": lang_name, "code2": lang_code} - - # Upload to Bazarr - if episode_client.upload_episode_subtitle( - series_id, episode_id, lang_code, subtitle_file - ): - episodes_uploads += 1 - print(f" โœ“ Uploaded {lang_name} subtitle to Bazarr") - - # Clean up tracking database for successful download - series_title = episode.get("seriesTitle", "Unknown") - season = episode.get("season", 0) - episode_num = episode.get("episode", 0) - episode_key = ( - f"{series_title}:S{season:02d}E{episode_num:02d}" - ) - tv_downloader.tracker.remove_successful_download( - episode_key, 0, lang_name.lower() - ) - - # Remove local file after successful upload - try: - os.remove(subtitle_file) - print(f" Cleaned up local file: {subtitle_file}") - except OSError: - pass - else: - print( - f" โœ— Failed to upload {lang_name} " - f"subtitle to Bazarr" - ) + + # Determine language from filename or default to first + # missing subtitle + missing_subs = episode.get("missing_subtitles", []) + if missing_subs: + lang_code = missing_subs[0].get("code2", "en") + lang_name = missing_subs[0].get("name", "Unknown") + else: + lang_code = "en" + lang_name = "English" + + if series_id and episode_id: + sub_info = {"name": lang_name, "code2": lang_code} + + # Upload to Bazarr + if bazarr.upload_episode_subtitle( + series_id, episode_id, lang_code, subtitle_file + ): + episodes_uploads += 1 + print(f" โœ“ Uploaded {lang_name} subtitle to Bazarr") + + # Get episode details to find subtitle path for post-processing + episode_data = bazarr.get_episode_subtitles( + series_id, episode_id + ) + if episode_data and "subtitles" in episode_data: + # Find the subtitle we just uploaded + for subtitle in episode_data["subtitles"]: + if subtitle.get("code2") == lang_code: + subtitle_path = subtitle.get("path") + if subtitle_path: + # Apply Sub-Zero modifications if enabled + if subzero_settings["enabled"]: + print( + " Applying Sub-Zero modifications..." + ) + bazarr.trigger_episode_subzero_mods( + subtitle_path=subtitle_path, + series_id=series_id, + episode_id=episode_id, + language=lang_code, + forced=False, + hi=False, + ) + + # Perform episode subtitle synchronization if enabled + if sync_settings["enabled"]: + print( + " Performing episode subtitle synchronization..." + ) + bazarr.sync_episode_subtitle( + subtitle_path=subtitle_path, + series_id=series_id, + episode_id=episode_id, + language=lang_code, + reference=sync_settings[ + "reference" + ], + max_offset_seconds=sync_settings[ + "max_offset_seconds" + ], + no_fix_framerate=sync_settings[ + "no_fix_framerate" + ], + use_gss=sync_settings["use_gss"], + ) + break + + # Clean up tracking database for successful download + series_title = episode.get("seriesTitle", "Unknown") + season = episode.get("season", 0) + episode_num = episode.get("episode", 0) + episode_key = ( + f"{series_title}:S{season:02d}E{episode_num:02d}" + ) + downloader.tracker.remove_successful_download( + episode_key, 0, lang_name.lower() + ) + + # Remove local file after successful upload + try: + os.remove(subtitle_file) + print(f" Cleaned up local file: {subtitle_file}") + except OSError: + pass else: - print(" โœ— Missing series_id or episode_id for upload") + print( + f" โœ— Failed to upload {lang_name} subtitle to Bazarr" + ) + else: + print(" โœ— Missing series_id or episode_id for upload") - # Small delay between episodes - if i < len(episodes): - time.sleep(1) + # Small delay between episodes + if i < len(episodes): + time.sleep(1) # Summary print("\n" + "=" * 50) diff --git a/tests/api/test_bazarr.py b/tests/api/test_bazarr.py index 9466cbc..302cb89 100644 --- a/tests/api/test_bazarr.py +++ b/tests/api/test_bazarr.py @@ -107,7 +107,7 @@ def test_upload_subtitle_success(self, mock_post, mock_basename, mock_open): mock_basename.return_value = "test.srt" mock_open.return_value.__enter__.return_value = Mock() - result = self.client.upload_subtitle_to_bazarr( + result = self.client.upload_movie_subtitle( radarr_id=123, subtitle_file="/path/to/test.srt", language="en", @@ -139,7 +139,7 @@ def test_upload_subtitle_request_exception( mock_basename.return_value = "test.srt" mock_open.return_value.__enter__.return_value = Mock() - result = self.client.upload_subtitle_to_bazarr( + result = self.client.upload_movie_subtitle( radarr_id=123, subtitle_file="/path/to/test.srt", language="en" ) @@ -148,7 +148,7 @@ def test_upload_subtitle_request_exception( @patch("builtins.open", side_effect=IOError("File not found")) def test_upload_subtitle_file_error(self, mock_open): """Test subtitle upload handles file errors.""" - result = self.client.upload_subtitle_to_bazarr( + result = self.client.upload_movie_subtitle( radarr_id=123, subtitle_file="/invalid/path/test.srt", language="en" ) @@ -254,6 +254,518 @@ def test_parse_interval_to_minutes_invalid_format(self): with self.assertRaises(ValueError): self.client._parse_interval_to_minutes("invalid format") + @patch("api.bazarr.requests.Session.patch") + def test_sync_subtitle_success(self, mock_patch): + """Test successful subtitle synchronization.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.sync_subtitle( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "sync") + self.assertEqual(params["language"], "en") + self.assertEqual(params["path"], "/path/to/subtitle.srt") + self.assertEqual(params["type"], "movie") + self.assertEqual(params["id"], 123) + + @patch("api.bazarr.requests.Session.patch") + def test_sync_subtitle_with_options(self, mock_patch): + """Test subtitle synchronization with custom options.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.sync_subtitle( + subtitle_path="/path/to/subtitle.srt", + media_type="episode", + media_id=456, + language="fr", + forced=True, + hi=True, + reference="a:1", + max_offset_seconds=600, + no_fix_framerate=True, + use_gss=True, + ) + + self.assertTrue(result) + + # Check custom parameters + call_args = mock_patch.call_args + params = call_args.kwargs["params"] + self.assertEqual(params["forced"], "true") + self.assertEqual(params["hi"], "true") + self.assertEqual(params["reference"], "a:1") + self.assertEqual(params["max_offset_seconds"], "600") + self.assertEqual(params["no_fix_framerate"], "true") + self.assertEqual(params["gss"], "true") + + @patch("api.bazarr.requests.Session.patch") + def test_sync_subtitle_exception(self, mock_patch): + """Test sync_subtitle handles exceptions.""" + mock_patch.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.sync_subtitle( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + ) + + self.assertFalse(result) + + @patch("api.bazarr.requests.Session.get") + def test_get_movie_subtitles_success(self, mock_get): + """Test successful get_movie_subtitles request.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "radarrid": 123, + "title": "Test Movie", + "subtitles": [ + { + "code2": "en", + "path": "/path/to/subtitle.srt", + "forced": False, + "hi": False, + } + ], + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = self.client.get_movie_subtitles(123) + + self.assertIsNotNone(result) + self.assertEqual(result["radarrid"], 123) + self.assertIn("subtitles", result) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_movie_subtitles_exception(self, mock_get): + """Test get_movie_subtitles handles exceptions.""" + mock_get.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.get_movie_subtitles(123) + + self.assertIsNone(result) + + # Episode-related tests + + @patch("api.bazarr.requests.Session.get") + def test_get_wanted_episodes_success(self, mock_get): + """Test successful retrieval of wanted episodes.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrEpisodeId": 123, + "sonarrSeriesId": 456, + "title": "Pilot", + "season": 1, + "episode": 1, + "missing_subtitles": [{"name": "English", "code2": "en"}], + }, + { + "sonarrEpisodeId": 124, + "sonarrSeriesId": 456, + "title": "Cat's in the Bag", + "season": 1, + "episode": 2, + "missing_subtitles": [{"name": "Spanish", "code2": "es"}], + }, + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + # Mock series enrichment + with patch.object(self.client, "_enrich_episode_data", side_effect=lambda x: x): + episodes = self.client.get_wanted_episodes() + + self.assertEqual(len(episodes), 2) + self.assertEqual(episodes[0]["title"], "Pilot") + self.assertEqual(episodes[0]["season"], 1) + self.assertEqual(episodes[0]["episode"], 1) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_series_info_success(self, mock_get): + """Test successful series info retrieval.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrSeriesId": 456, + "title": "Breaking Bad", + "year": 2008, + "imdbId": "tt0903747", + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + series_info = self.client.get_series_info(456) + + self.assertIsNotNone(series_info) + self.assertEqual(series_info["title"], "Breaking Bad") + self.assertEqual(series_info["year"], 2008) + + @patch("api.bazarr.requests.Session.post") + def test_upload_episode_subtitle_success(self, mock_post): + """Test successful episode subtitle upload.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Create temporary subtitle file + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f: + f.write("1\n00:00:01,000 --> 00:00:02,000\nTest subtitle\n") + temp_file = f.name + + try: + result = self.client.upload_episode_subtitle( + series_id=456, + episode_id=123, + language="en", + subtitle_file=temp_file, + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_post.assert_called_once() + + # Check call parameters + call_args = mock_post.call_args + self.assertIn("params", call_args.kwargs) + self.assertEqual(call_args.kwargs["params"]["seriesid"], 456) + self.assertEqual(call_args.kwargs["params"]["episodeid"], 123) + self.assertEqual(call_args.kwargs["params"]["language"], "en") + + finally: + import os + + os.unlink(temp_file) + + @patch("api.bazarr.requests.Session.patch") + def test_sync_episode_subtitle_success(self, mock_patch): + """Test successful episode subtitle synchronization.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.sync_episode_subtitle( + subtitle_path="/path/to/episode.srt", + series_id=456, + episode_id=123, + language="en", + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "sync") + self.assertEqual(params["language"], "en") + self.assertEqual(params["path"], "/path/to/episode.srt") + self.assertEqual(params["type"], "episode") + self.assertEqual(params["id"], 123) + + @patch("api.bazarr.requests.Session.get") + def test_get_episode_subtitles_success(self, mock_get): + """Test successful get_episode_subtitles request.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "sonarrEpisodeId": 123, + "title": "Test Episode", + "subtitles": [ + { + "code2": "en", + "path": "/path/to/episode.srt", + "forced": False, + "hi": False, + } + ], + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = self.client.get_episode_subtitles(456, 123) + + self.assertIsNotNone(result) + self.assertEqual(result["sonarrEpisodeId"], 123) + self.assertIn("subtitles", result) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_system_settings_success(self, mock_get): + """Test successful get_system_settings request.""" + mock_response = Mock() + mock_response.json.return_value = { + "subsync": { + "max_offset_seconds": 300, + "no_fix_framerate": True, + "gss": False, + }, + "general": {"use_subsync": True}, + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = self.client.get_system_settings() + + self.assertIsNotNone(result) + self.assertIn("subsync", result) + mock_get.assert_called_once() + + @patch("api.bazarr.requests.Session.get") + def test_get_system_settings_exception(self, mock_get): + """Test get_system_settings handles exceptions.""" + mock_get.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.get_system_settings() + + self.assertIsNone(result) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_success(self, mock_get_settings): + """Test successful sync settings retrieval.""" + mock_get_settings.return_value = { + "subsync": { + "use_subsync": False, + "max_offset_seconds": 600, + "no_fix_framerate": True, + "gss": True, + } + } + + result = self.client.get_sync_settings() + + expected = { + "enabled": False, + "max_offset_seconds": 600, + "no_fix_framerate": True, + "use_gss": True, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_no_subsync_section(self, mock_get_settings): + """Test sync settings with missing subsync section.""" + mock_get_settings.return_value = {"general": {"some_setting": True}} + + result = self.client.get_sync_settings() + + expected = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_api_failure(self, mock_get_settings): + """Test sync settings when API call fails.""" + mock_get_settings.return_value = None + + result = self.client.get_sync_settings() + + expected = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_sync_settings_enabled(self, mock_get_settings): + """Test sync settings when SubSync is enabled.""" + mock_get_settings.return_value = { + "subsync": { + "use_subsync": True, + "max_offset_seconds": 600, + "no_fix_framerate": False, + "gss": True, + } + } + + result = self.client.get_sync_settings() + + expected = { + "enabled": True, + "max_offset_seconds": 600, + "no_fix_framerate": False, + "use_gss": True, + "reference": "a:0", + } + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_enabled(self, mock_get_settings): + """Test Sub-Zero settings when modifications are enabled.""" + mock_get_settings.return_value = { + "general": {"subzero_mods": ["common", "hearing_impaired"]} + } + + result = self.client.get_subzero_settings() + + expected = {"mods": ["common", "hearing_impaired"], "enabled": True} + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_disabled(self, mock_get_settings): + """Test Sub-Zero settings when no modifications are configured.""" + mock_get_settings.return_value = {"general": {"subzero_mods": []}} + + result = self.client.get_subzero_settings() + + expected = {"mods": [], "enabled": False} + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_missing_section(self, mock_get_settings): + """Test Sub-Zero settings when general section is missing.""" + mock_get_settings.return_value = {"subsync": {"use_subsync": True}} + + result = self.client.get_subzero_settings() + + expected = {"mods": [], "enabled": False} + self.assertEqual(result, expected) + + @patch.object(Bazarr, "get_system_settings") + def test_get_subzero_settings_api_failure(self, mock_get_settings): + """Test Sub-Zero settings when API call fails.""" + mock_get_settings.return_value = None + + result = self.client.get_subzero_settings() + + expected = {"mods": [], "enabled": False} + self.assertEqual(result, expected) + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_subzero_mods_success(self, mock_patch): + """Test successful Sub-Zero modification trigger.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.trigger_subzero_mods( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + forced=False, + hi=False, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "subzero") + self.assertEqual(params["language"], "en") + self.assertEqual(params["path"], "/path/to/subtitle.srt") + self.assertEqual(params["type"], "movie") + self.assertEqual(params["id"], 123) + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_subzero_mods_exception(self, mock_patch): + """Test Sub-Zero trigger handles exceptions.""" + mock_patch.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.trigger_subzero_mods( + subtitle_path="/path/to/subtitle.srt", + media_type="movie", + media_id=123, + language="en", + ) + + self.assertFalse(result) + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_episode_subzero_mods_success(self, mock_patch): + """Test successful episode Sub-Zero modification trigger.""" + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_patch.return_value = mock_response + + result = self.client.trigger_episode_subzero_mods( + subtitle_path="/path/to/episode.srt", + series_id=456, + episode_id=123, + language="fr", + forced=True, + hi=True, + ) + + self.assertTrue(result) + mock_patch.assert_called_once() + + # Check the call parameters + call_args = mock_patch.call_args + self.assertIn("params", call_args.kwargs) + params = call_args.kwargs["params"] + self.assertEqual(params["action"], "subzero") + self.assertEqual(params["language"], "fr") + self.assertEqual(params["path"], "/path/to/episode.srt") + self.assertEqual(params["type"], "episode") + self.assertEqual(params["id"], 123) + self.assertEqual(params["forced"], "true") + self.assertEqual(params["hi"], "true") + + @patch("api.bazarr.requests.Session.patch") + def test_trigger_episode_subzero_mods_exception(self, mock_patch): + """Test episode Sub-Zero trigger handles exceptions.""" + mock_patch.side_effect = requests.exceptions.RequestException("Network error") + + result = self.client.trigger_episode_subzero_mods( + subtitle_path="/path/to/episode.srt", + series_id=456, + episode_id=123, + language="en", + ) + + self.assertFalse(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/api/test_bazarr_episodes.py b/tests/api/test_bazarr_episodes.py deleted file mode 100644 index 9c15993..0000000 --- a/tests/api/test_bazarr_episodes.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -Test cases for Bazarr Episode API client. -""" - -import os -import tempfile -import unittest -from unittest.mock import Mock, patch - -from api.bazarr_episodes import BazarrEpisodeClient - - -class TestBazarrEpisodeClient(unittest.TestCase): - """Test cases for Bazarr Episode API client.""" - - def setUp(self): - """Set up test client.""" - self.client = BazarrEpisodeClient( - "http://localhost:6767", "test-api-key", "testuser", "testpass" - ) - - @patch("api.bazarr_episodes.requests.Session.get") - def test_get_wanted_episodes_success(self, mock_get): - """Test successful retrieval of wanted episodes.""" - # Mock response data - mock_response = Mock() - mock_response.json.return_value = { - "data": [ - { - "sonarrEpisodeId": 123, - "sonarrSeriesId": 456, - "title": "Pilot", - "season": 1, - "episode": 1, - "missing_subtitles": [{"name": "English", "code2": "en"}], - "sceneName": "Breaking.Bad.S01E01.1080p.BluRay.x264-REWARD", - }, - { - "sonarrEpisodeId": 124, - "sonarrSeriesId": 456, - "title": "Cat's in the Bag", - "season": 1, - "episode": 2, - "missing_subtitles": [{"name": "Spanish", "code2": "es"}], - }, - ] - } - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - # Mock series enrichment - with patch.object(self.client, "_enrich_episode_data", side_effect=lambda x: x): - episodes = self.client.get_wanted_episodes() - - self.assertEqual(len(episodes), 2) - self.assertEqual(episodes[0]["title"], "Pilot") - self.assertEqual(episodes[0]["season"], 1) - self.assertEqual(episodes[0]["episode"], 1) - mock_get.assert_called_once() - - @patch("api.bazarr_episodes.requests.Session.get") - def test_get_wanted_episodes_empty(self, mock_get): - """Test retrieval when no episodes want subtitles.""" - mock_response = Mock() - mock_response.json.return_value = {"data": []} - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - episodes = self.client.get_wanted_episodes() - - self.assertEqual(len(episodes), 0) - - @patch("api.bazarr_episodes.requests.Session.get") - def test_get_series_info_success(self, mock_get): - """Test successful series info retrieval.""" - mock_response = Mock() - mock_response.json.return_value = { - "data": [ - { - "sonarrSeriesId": 456, - "title": "Breaking Bad", - "year": 2008, - "imdbId": "tt0903747", - } - ] - } - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - series_info = self.client.get_series_info(456) - - self.assertIsNotNone(series_info) - self.assertEqual(series_info["title"], "Breaking Bad") - self.assertEqual(series_info["year"], 2008) - - @patch("api.bazarr_episodes.requests.Session.get") - def test_get_series_info_not_found(self, mock_get): - """Test series info when series not found.""" - mock_response = Mock() - mock_response.json.return_value = {"data": []} - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - series_info = self.client.get_series_info(999) - - self.assertIsNone(series_info) - - def test_enrich_episode_data_success(self): - """Test episode data enrichment.""" - episode = { - "sonarrEpisodeId": 123, - "sonarrSeriesId": 456, - "title": "Pilot", - } - - with patch.object(self.client, "get_series_info") as mock_get_series: - mock_get_series.return_value = { - "title": "Breaking Bad", - "year": 2008, - "imdbId": "tt0903747", - } - - enriched = self.client._enrich_episode_data(episode) - - self.assertEqual(enriched["seriesTitle"], "Breaking Bad") - self.assertEqual(enriched["seriesYear"], 2008) - self.assertEqual(enriched["seriesImdb"], "tt0903747") - - def test_enrich_episode_data_no_series_id(self): - """Test episode enrichment when no series ID.""" - episode = { - "sonarrEpisodeId": 123, - "title": "Pilot", - } - - enriched = self.client._enrich_episode_data(episode) - - # Should return original episode - self.assertEqual(enriched, episode) - - @patch("api.bazarr_episodes.requests.Session.post") - def test_upload_episode_subtitle_success(self, mock_post): - """Test successful episode subtitle upload.""" - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response - - # Create temporary subtitle file - with tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False) as f: - f.write("1\n00:00:01,000 --> 00:00:02,000\nTest subtitle\n") - temp_file = f.name - - try: - result = self.client.upload_episode_subtitle( - series_id=456, - episode_id=123, - language="en", - subtitle_file=temp_file, - forced=False, - hi=False, - ) - - self.assertTrue(result) - mock_post.assert_called_once() - - # Check call parameters - call_args = mock_post.call_args - self.assertIn("seriesid", call_args[1]["params"]) - self.assertEqual(call_args[1]["params"]["seriesid"], 456) - self.assertEqual(call_args[1]["params"]["episodeid"], 123) - self.assertEqual(call_args[1]["params"]["language"], "en") - - finally: - os.unlink(temp_file) - - def test_upload_episode_subtitle_file_not_found(self): - """Test subtitle upload with non-existent file.""" - result = self.client.upload_episode_subtitle( - series_id=456, - episode_id=123, - language="en", - subtitle_file="/non/existent/file.srt", - ) - - self.assertFalse(result) - - @patch("api.bazarr_episodes.requests.Session.get") - def test_get_search_interval_success(self, mock_get): - """Test getting search interval from Bazarr.""" - mock_response = Mock() - mock_response.json.return_value = {"general": {"episode_search_interval": 12}} - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - interval = self.client.get_search_interval() - - self.assertEqual(interval, 12) - - @patch("api.bazarr_episodes.requests.Session.get") - def test_get_search_interval_fallback(self, mock_get): - """Test search interval fallback when API fails.""" - mock_get.side_effect = Exception("API Error") - - interval = self.client.get_search_interval() - - self.assertEqual(interval, 24) # Default fallback - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/api/test_subsource.py b/tests/api/test_subsource.py index 6756c2f..5bb5da9 100644 --- a/tests/api/test_subsource.py +++ b/tests/api/test_subsource.py @@ -47,7 +47,7 @@ def test_init(self): # Check session headers expected_headers = { "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36" + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" ), "Accept": "application/json", "Content-Type": "application/json", @@ -382,6 +382,137 @@ def test_get_subtitle_for_movie_with_tracking(self, mock_interval): self.assertEqual(len(downloaded_files), 0) self.assertEqual(skipped_count, 1) + # TV Series / Episode tests + + def test_generate_episode_search_queries(self): + """Test generation of episode search queries.""" + episode = { + "seriesTitle": "Breaking Bad", + "title": "Pilot", + "season": 1, + "episode": 1, + "sceneName": "Breaking.Bad.S01E01.1080p.BluRay.x264-REWARD", + } + + queries = self.downloader._generate_episode_search_queries(episode) + + self.assertEqual(len(queries), 4) + self.assertIn("Breaking Bad S01E01", queries) + self.assertIn("Breaking Bad Pilot", queries) + self.assertIn("Breaking Bad", queries) + + def test_generate_episode_search_queries_minimal(self): + """Test query generation with minimal episode data.""" + episode = {"seriesTitle": "Test Show"} + + queries = self.downloader._generate_episode_search_queries(episode) + + self.assertEqual(queries, ["Test Show"]) + + def test_extract_episode_info_s01e01_format(self): + """Test episode info extraction from S01E01 format.""" + subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertEqual(season, 1) + self.assertEqual(episode, 1) + + def test_extract_episode_info_1x01_format(self): + """Test episode info extraction from 1x01 format.""" + subtitle = {"release_info": "Breaking.Bad.1x01.720p.BluRay.x264-REWARD"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertEqual(season, 1) + self.assertEqual(episode, 1) + + def test_extract_episode_info_no_match(self): + """Test episode info extraction when no pattern matches.""" + subtitle = {"release_info": "Breaking.Bad.720p.BluRay.x264-REWARD"} + + season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + + self.assertIsNone(season) + self.assertIsNone(episode) + + def test_is_subtitle_match_success(self): + """Test subtitle matching with correct season/episode.""" + subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} + target_episode = {"season": 1, "episode": 1} + + result = self.downloader._is_subtitle_match(subtitle, target_episode) + + self.assertTrue(result) + + def test_is_subtitle_match_failure(self): + """Test subtitle matching with wrong season/episode.""" + subtitle = {"release_info": "Breaking.Bad.S01E02.720p.BluRay.x264-REWARD"} + target_episode = {"season": 1, "episode": 1} + + result = self.downloader._is_subtitle_match(subtitle, target_episode) + + self.assertFalse(result) + + @patch("api.subsource.requests.Session.post") + @patch("api.subsource.requests.Session.get") + def test_search_episode_subtitles_success(self, mock_get, mock_post): + """Test successful episode subtitle search.""" + # Mock search response + mock_search_response = Mock() + mock_search_response.json.return_value = { + "results": [ + { + "title": "Breaking Bad", + "link": "/subtitles/breaking-bad-2008", + } + ] + } + mock_search_response.raise_for_status.return_value = None + mock_post.return_value = mock_search_response + + # Mock subtitles response + mock_sub_response = Mock() + mock_sub_response.json.return_value = [ + { + "id": "123", + "release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD", + "language": "english", + } + ] + mock_sub_response.raise_for_status.return_value = None + mock_get.return_value = mock_sub_response + + episode = { + "seriesTitle": "Breaking Bad", + "season": 1, + "episode": 1, + } + + results = self.downloader.search_episode_subtitles(episode, "english") + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "123") + self.assertIn("source_query", results[0]) + + @patch("api.subsource.requests.Session.post") + def test_search_episode_subtitles_no_results(self, mock_post): + """Test episode subtitle search with no results.""" + mock_response = Mock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + episode = { + "seriesTitle": "Unknown Show", + "season": 1, + "episode": 1, + } + + results = self.downloader.search_episode_subtitles(episode, "english") + + self.assertEqual(len(results), 0) + if __name__ == "__main__": unittest.main() diff --git a/tests/api/test_tv_shows.py b/tests/api/test_tv_shows.py deleted file mode 100644 index ceca2a7..0000000 --- a/tests/api/test_tv_shows.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Test cases for TV Show SubSource API client. -""" - -import tempfile -import unittest -from unittest.mock import Mock, patch - -from api.tv_shows import TVShowSubSourceDownloader - - -class TestTVShowSubSourceDownloader(unittest.TestCase): - """Test cases for TV Show SubSource API client.""" - - def setUp(self): - """Set up test downloader.""" - with tempfile.TemporaryDirectory() as temp_dir: - self.temp_dir = temp_dir - self.downloader = TVShowSubSourceDownloader( - "https://api.test.com", temp_dir - ) - - def test_generate_episode_search_queries(self): - """Test generation of episode search queries.""" - episode = { - "seriesTitle": "Breaking Bad", - "title": "Pilot", - "season": 1, - "episode": 1, - "sceneName": "Breaking.Bad.S01E01.1080p.BluRay.x264-REWARD", - } - - queries = self.downloader._generate_episode_search_queries(episode) - - self.assertEqual(len(queries), 4) - self.assertIn("Breaking Bad S01E01", queries) - self.assertIn("Breaking Bad Pilot", queries) - self.assertIn("Breaking Bad", queries) - - def test_generate_episode_search_queries_minimal(self): - """Test query generation with minimal episode data.""" - episode = {"seriesTitle": "Test Show"} - - queries = self.downloader._generate_episode_search_queries(episode) - - self.assertEqual(queries, ["Test Show"]) - - def test_extract_episode_info_s01e01_format(self): - """Test episode info extraction from S01E01 format.""" - subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} - - season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) - - self.assertEqual(season, 1) - self.assertEqual(episode, 1) - - def test_extract_episode_info_1x01_format(self): - """Test episode info extraction from 1x01 format.""" - subtitle = {"release_info": "Breaking.Bad.1x01.720p.HDTV.x264-CTU"} - - season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) - - self.assertEqual(season, 1) - self.assertEqual(episode, 1) - - def test_extract_episode_info_no_match(self): - """Test episode info extraction when no pattern matches.""" - subtitle = {"release_info": "Breaking.Bad.Complete.Series.BluRay"} - - season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) - - self.assertIsNone(season) - self.assertIsNone(episode) - - def test_is_subtitle_match_success(self): - """Test subtitle matching success.""" - subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} - episode = {"season": 1, "episode": 1} - - is_match = self.downloader._is_subtitle_match(subtitle, episode) - - self.assertTrue(is_match) - - def test_is_subtitle_match_failure(self): - """Test subtitle matching failure.""" - subtitle = {"release_info": "Breaking.Bad.S01E02.720p.BluRay.x264-REWARD"} - episode = {"season": 1, "episode": 1} - - is_match = self.downloader._is_subtitle_match(subtitle, episode) - - self.assertFalse(is_match) - - def test_is_subtitle_match_no_episode_info(self): - """Test subtitle matching when episode has no season/episode.""" - subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} - episode = {"title": "Pilot"} - - is_match = self.downloader._is_subtitle_match(subtitle, episode) - - self.assertFalse(is_match) - - @patch("api.tv_shows.requests.Session.post") - @patch("api.tv_shows.requests.Session.get") - @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") - def test_search_episode_subtitles_success(self, mock_interval, mock_get, mock_post): - """Test successful episode subtitle search.""" - # Mock interval hours - mock_interval.return_value = 24 - - # Mock movie search response - movie_search_response = Mock() - movie_search_response.json.return_value = { - "results": [ - { - "title": "Breaking Bad S01E01", - "link": "/subtitles/breaking-bad-s01e01-pilot", - "type": "movie", - } - ] - } - movie_search_response.raise_for_status.return_value = None - mock_post.return_value = movie_search_response - - # Mock subtitle fetch response - subtitle_response = Mock() - subtitle_response.json.return_value = [ - { - "id": "12345", - "language": "English", - "release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD", - } - ] - subtitle_response.raise_for_status.return_value = None - mock_get.return_value = subtitle_response - - episode = { - "seriesTitle": "Breaking Bad", - "title": "Pilot", - "season": 1, - "episode": 1, - } - - results = self.downloader.search_episode_subtitles(episode, "english") - - self.assertEqual(len(results), 1) - self.assertEqual(results[0]["id"], "12345") - # Source query could be any of the tried queries due to deduplication - self.assertIn( - results[0]["source_query"], - ["Breaking Bad S01E01", "Breaking Bad Pilot", "Breaking Bad"], - ) - - @patch("api.tv_shows.requests.Session.post") - @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") - def test_search_episode_subtitles_no_results(self, mock_interval, mock_post): - """Test episode search with no results.""" - # Mock interval hours - mock_interval.return_value = 24 - - # Mock empty search response - mock_response = Mock() - mock_response.json.return_value = {"results": []} - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response - - episode = { - "seriesTitle": "Nonexistent Show", - "season": 1, - "episode": 1, - } - - results = self.downloader.search_episode_subtitles(episode) - - self.assertEqual(len(results), 0) - - @patch.object(TVShowSubSourceDownloader, "search_episode_subtitles") - @patch.object(TVShowSubSourceDownloader, "download_subtitle") - @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") - def test_get_subtitle_for_episode(self, mock_interval, mock_download, mock_search): - """Test getting subtitles for an episode.""" - # Mock interval hours - mock_interval.return_value = 24 - - # Mock search results - mock_search.return_value = [ - {"id": "12345", "language": "English"}, - ] - - # Mock download - mock_download.return_value = "/path/to/subtitle.srt" - - # Mock tracker to not skip searches - with patch.object( - self.downloader.tracker, "should_skip_search", return_value=False - ): - episode = { - "seriesTitle": "Breaking Bad", - "season": 1, - "episode": 1, - "missing_subtitles": [{"name": "English", "code2": "en"}], - } - - downloaded_files, skipped_count = self.downloader.get_subtitle_for_episode( - episode - ) - - self.assertEqual(len(downloaded_files), 1) - self.assertEqual(skipped_count, 0) - self.assertEqual(downloaded_files[0], "/path/to/subtitle.srt") - - @patch.object(TVShowSubSourceDownloader, "_get_search_interval_hours") - def test_get_subtitle_for_episode_with_tracking_skip(self, mock_interval): - """Test getting subtitles when tracking suggests skipping.""" - mock_interval.return_value = 24 - - # Mock tracker to skip searches - with patch.object( - self.downloader.tracker, "should_skip_search", return_value=True - ): - episode = { - "seriesTitle": "Breaking Bad", - "season": 1, - "episode": 1, - "missing_subtitles": [{"name": "English", "code2": "en"}], - } - - downloaded_files, skipped_count = self.downloader.get_subtitle_for_episode( - episode - ) - - self.assertEqual(len(downloaded_files), 0) - self.assertEqual(skipped_count, 1) - - @patch("api.subsource.SubSourceDownloader") - def test_download_subtitle_delegates_to_movie_downloader( - self, mock_movie_downloader_class - ): - """Test that download_subtitle delegates to movie downloader.""" - # Mock the movie downloader instance - mock_instance = Mock() - mock_instance.download_subtitle.return_value = "/path/to/downloaded.srt" - mock_movie_downloader_class.return_value = mock_instance - - subtitle = {"id": "12345", "download_url": "http://test.com/sub.zip"} - filename = "test.srt" - - result = self.downloader.download_subtitle(subtitle, filename) - - self.assertEqual(result, "/path/to/downloaded.srt") - mock_instance.download_subtitle.assert_called_once_with(subtitle, filename) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_run.py b/tests/test_run.py index a58b659..40736c0 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -35,12 +35,21 @@ def test_main_no_movies( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = {"data": []} + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock logging setup @@ -90,6 +99,7 @@ def test_main_with_movies_success( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config @@ -109,7 +119,30 @@ def test_main_with_movies_success( # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = movies_data - mock_bazarr.upload_subtitle_to_bazarr.return_value = True + mock_bazarr.upload_movie_subtitle.return_value = True + mock_bazarr.get_movie_subtitles.return_value = { + "subtitles": [ + { + "code2": "en", + "forced": False, + "hi": False, + "path": "/path/to/subtitle.srt", + } + ] + } + mock_bazarr.sync_subtitle.return_value = True + mock_bazarr.trigger_subzero_mods.return_value = True + mock_bazarr.get_sync_settings.return_value = { + "enabled": True, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = { + "mods": ["common"], + "enabled": True, + } mock_bazarr_class.return_value = mock_bazarr # Mock SubSource downloader @@ -130,7 +163,7 @@ def test_main_with_movies_success( # Verify subtitle processing mock_downloader.get_subtitle_for_movie.assert_called_once() - mock_bazarr.upload_subtitle_to_bazarr.assert_called_once_with( + mock_bazarr.upload_movie_subtitle.assert_called_once_with( 123, "/tmp/test.srt", "en", False, False ) @@ -162,6 +195,14 @@ def test_main_bazarr_api_failure( # Mock Bazarr client to return None (API failure) mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = None + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock logging @@ -233,6 +274,7 @@ def test_main_no_radarr_id( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config @@ -251,6 +293,14 @@ def test_main_no_radarr_id( # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = movies_data + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock SubSource downloader @@ -292,6 +342,7 @@ def test_main_no_subtitles_downloaded( "password": "test_pass", "subsource_api_url": "https://api.test.com", "download_directory": "/tmp", + "episodes_enabled": False, # Disable episodes for this test } mock_load_config.return_value = mock_config @@ -311,6 +362,14 @@ def test_main_no_subtitles_downloaded( # Mock Bazarr client mock_bazarr = Mock() mock_bazarr.get_wanted_movies.return_value = movies_data + mock_bazarr.get_sync_settings.return_value = { + "enabled": False, + "max_offset_seconds": 300, + "no_fix_framerate": False, + "use_gss": False, + "reference": "a:0", + } + mock_bazarr.get_subzero_settings.return_value = {"mods": [], "enabled": False} mock_bazarr_class.return_value = mock_bazarr # Mock SubSource downloader to return no files diff --git a/utils.py b/utils.py index 6a19237..b2ac484 100644 --- a/utils.py +++ b/utils.py @@ -19,13 +19,6 @@ def format_movie_info(movie: Dict) -> str: Formatted string with movie information """ title = movie.get("title", "Unknown Title").strip() - # Try different possible year field names - year = ( - movie.get("year") - or movie.get("movie_year") - or movie.get("releaseYear") - or movie.get("release_year") - ) missing_subs = movie.get("missing_subtitles", []) # Format missing subtitles languages @@ -45,8 +38,46 @@ def format_movie_info(movie: Dict) -> str: missing_langs = ", ".join(languages) if languages else "Unknown" - # Include year if available - if year: - return f"โ€ข {title} ({year}) - Missing: {missing_langs}" + return f"โ€ข {title} - Missing: {missing_langs}" + + +def format_episode_info(episode: Dict) -> str: + """ + Format episode information for display. + + Args: + episode: Episode dictionary from API response + + Returns: + Formatted string with episode information + """ + series_title = episode.get("series_title") + season = episode.get("season") + episode_number = episode.get("episode_number") + episode_title = episode.get("episode_title") + missing_subs = episode.get("missing_subtitles", []) + + # Format missing subtitles languages + languages = [] + for sub in missing_subs: + lang_name = sub.get("name", "Unknown") + forced = sub.get("forced", False) + hi = sub.get("hi", False) + + lang_desc = lang_name + if forced: + lang_desc += " (Forced)" + if hi: + lang_desc += " (HI)" + + languages.append(lang_desc) + + missing_langs = ", ".join(languages) if languages else "Unknown" + + # Format season and episode number + if season is not None and episode_number is not None: + season_episode = f"S{season}E{episode_number}" else: - return f"โ€ข {title} - Missing: {missing_langs}" + season_episode = "S??E??" + + return f"โ€ข {series_title} {season_episode} - {episode_title} - Missing: {missing_langs}" From 645c1de8e9d20cc76fc9317edda76f9be38ef662 Mon Sep 17 00:00:00 2001 From: MD Maksudur Rahman Khan Date: Tue, 21 Oct 2025 12:14:10 +0200 Subject: [PATCH 3/5] fix: resolve test failures in subsource and utils modules - Fixed episode info extraction regex priority (S01E01 pattern now checked first) - Added year display support in format_movie_info for multiple year fields - Updated episode subtitle tests to match current API structure - Fixed _is_subtitle_match to support both episode_number and episode fields - Removed obsolete _generate_episode_search_queries tests (method removed) - All 113 tests now passing --- api/subsource.py | 22 +++++++++++---------- tests/api/test_subsource.py | 39 ++++++++++--------------------------- utils.py | 16 ++++++++++++++- 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/api/subsource.py b/api/subsource.py index 5bd4e17..7033613 100644 --- a/api/subsource.py +++ b/api/subsource.py @@ -576,15 +576,7 @@ def _extract_episode_info_from_subtitle( """ release_info = subtitle.get("release_info", "") - # Look for E01 pattern (simplified) - episode_match = re.search(r"[Ee](\d+)", release_info) - if episode_match: - episode = int(episode_match.group(1)) - # For E01 format, we don't extract season from the subtitle - # We rely on the season context from the search - return None, episode - - # Fallback: Look for S01E01 pattern for compatibility + # First, look for S01E01 pattern (most specific) season_episode_match = re.search(r"[Ss](\d+)[Ee](\d+)", release_info) if season_episode_match: season = int(season_episode_match.group(1)) @@ -598,6 +590,14 @@ def _extract_episode_info_from_subtitle( episode = int(alt_pattern.group(2)) return season, episode + # Fallback: Look for standalone E01 pattern (least specific) + episode_match = re.search(r"[Ee](\d+)", release_info) + if episode_match: + episode = int(episode_match.group(1)) + # For E01 format, we don't extract season from the subtitle + # We rely on the season context from the search + return None, episode + return None, None def _find_best_series_match( @@ -758,7 +758,9 @@ def _is_subtitle_match(self, subtitle: Dict, target_episode: Dict) -> bool: True if subtitle matches episode """ target_season = target_episode.get("season") - target_episode_num = target_episode.get("episode") + target_episode_num = target_episode.get("episode_number") or target_episode.get( + "episode" + ) if not target_season or not target_episode_num: return False diff --git a/tests/api/test_subsource.py b/tests/api/test_subsource.py index 5bb5da9..f09f2ae 100644 --- a/tests/api/test_subsource.py +++ b/tests/api/test_subsource.py @@ -384,37 +384,13 @@ def test_get_subtitle_for_movie_with_tracking(self, mock_interval): # TV Series / Episode tests - def test_generate_episode_search_queries(self): - """Test generation of episode search queries.""" - episode = { - "seriesTitle": "Breaking Bad", - "title": "Pilot", - "season": 1, - "episode": 1, - "sceneName": "Breaking.Bad.S01E01.1080p.BluRay.x264-REWARD", - } - - queries = self.downloader._generate_episode_search_queries(episode) - - self.assertEqual(len(queries), 4) - self.assertIn("Breaking Bad S01E01", queries) - self.assertIn("Breaking Bad Pilot", queries) - self.assertIn("Breaking Bad", queries) - - def test_generate_episode_search_queries_minimal(self): - """Test query generation with minimal episode data.""" - episode = {"seriesTitle": "Test Show"} - - queries = self.downloader._generate_episode_search_queries(episode) - - self.assertEqual(queries, ["Test Show"]) - def test_extract_episode_info_s01e01_format(self): """Test episode info extraction from S01E01 format.""" subtitle = {"release_info": "Breaking.Bad.S01E01.720p.BluRay.x264-REWARD"} season, episode = self.downloader._extract_episode_info_from_subtitle(subtitle) + # S01E01 format should return both season and episode self.assertEqual(season, 1) self.assertEqual(episode, 1) @@ -464,7 +440,12 @@ def test_search_episode_subtitles_success(self, mock_get, mock_post): "results": [ { "title": "Breaking Bad", + "type": "tvseries", "link": "/subtitles/breaking-bad-2008", + "seasons": [ + {"season": 1, "link": "/subtitles/breaking-bad-2008/s1"} + ], + "releaseYear": 2008, } ] } @@ -484,9 +465,9 @@ def test_search_episode_subtitles_success(self, mock_get, mock_post): mock_get.return_value = mock_sub_response episode = { - "seriesTitle": "Breaking Bad", + "series_title": "Breaking Bad", "season": 1, - "episode": 1, + "episode_number": 1, } results = self.downloader.search_episode_subtitles(episode, "english") @@ -504,9 +485,9 @@ def test_search_episode_subtitles_no_results(self, mock_post): mock_post.return_value = mock_response episode = { - "seriesTitle": "Unknown Show", + "series_title": "Unknown Show", "season": 1, - "episode": 1, + "episode_number": 1, } results = self.downloader.search_episode_subtitles(episode, "english") diff --git a/utils.py b/utils.py index b2ac484..494daa9 100644 --- a/utils.py +++ b/utils.py @@ -21,6 +21,20 @@ def format_movie_info(movie: Dict) -> str: title = movie.get("title", "Unknown Title").strip() missing_subs = movie.get("missing_subtitles", []) + # Get year from multiple possible fields + year = ( + movie.get("year") + or movie.get("movie_year") + or movie.get("releaseYear") + or movie.get("release_year") + ) + + # Format title with year if available + if year: + title_with_year = f"{title} ({year})" + else: + title_with_year = title + # Format missing subtitles languages languages = [] for sub in missing_subs: @@ -38,7 +52,7 @@ def format_movie_info(movie: Dict) -> str: missing_langs = ", ".join(languages) if languages else "Unknown" - return f"โ€ข {title} - Missing: {missing_langs}" + return f"โ€ข {title_with_year} - Missing: {missing_langs}" def format_episode_info(episode: Dict) -> str: From 7f0f644b1e4850f73a189fb20aac62c83e843ff7 Mon Sep 17 00:00:00 2001 From: MD Maksudur Rahman Khan Date: Sat, 8 Nov 2025 13:14:06 +0100 Subject: [PATCH 4/5] feat: add cloudflare bypass for subsource api access - Added comprehensive browser-like headers for Cloudflare protection bypass - Included all required sec-fetch-* and sec-ch-ua-* headers - Added support for cf_clearance cookie via SUBSOURCE_CF_CLEARANCE env variable - Updated User-Agent to match modern Chrome/Edge browser - Added Origin and Referer headers for CORS compliance - Fixes 403 Forbidden errors from SubSource API Tested with successful subtitle download, upload, Sub-Zero mods, and sync. --- api/subsource.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/api/subsource.py b/api/subsource.py index 7033613..661a0f1 100644 --- a/api/subsource.py +++ b/api/subsource.py @@ -29,18 +29,33 @@ def __init__(self, api_url: str, download_dir: str, bazarr=None): self._search_interval_hours = None self._movie_years_cache = {} # Cache movie years to avoid repeated API calls - # Setup optimized session headers + # Setup optimized session headers with Cloudflare bypass headers self.session.headers.update( { - "Accept": "application/json", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en,bn;q=0.9,en-US;q=0.8", "Content-Type": "application/json", - "Connection": "keep-alive", - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" - ), + "Cache-Control": "no-cache", + "DNT": "1", + "Origin": "https://subsource.net", + "Pragma": "no-cache", + "Referer": "https://subsource.net/", + "Sec-CH-UA": '"Chromium";v="142", "Microsoft Edge";v="142", "Not_A Brand";v="99"', + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0", } ) + # Set Cloudflare clearance cookie if available from environment + cf_clearance = os.environ.get("SUBSOURCE_CF_CLEARANCE") + if cf_clearance: + self.session.cookies.set("cf_clearance", cf_clearance) + logger.info("Using Cloudflare clearance cookie from environment") + # Create download directory if it doesn't exist os.makedirs(download_dir, exist_ok=True) From 055789cf101d8f4ae07b6e1d3e2e05e9960a5009 Mon Sep 17 00:00:00 2001 From: MD Maksudur Rahman Khan Date: Sat, 8 Nov 2025 13:30:35 +0100 Subject: [PATCH 5/5] feat: add config file support for cloudflare clearance cookie - Added cf_clearance field to subsource config section - Cookie can now be stored in config file instead of environment variable - Priority: environment variable > config file - Updated SubSourceDownloader to accept cf_clearance parameter - Added helpful comments in config template for getting the cookie - Updated all tests to handle new parameter - All 113 tests passing Users can now set the cookie once in config file and don't need to export the environment variable every time they run the application. --- api/subsource.py | 17 +++++++++++------ core/config.py | 12 +++++++++++- run.py | 1 + tests/api/test_subsource.py | 11 +++++------ tests/test_run.py | 2 +- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/api/subsource.py b/api/subsource.py index 661a0f1..fb34adb 100644 --- a/api/subsource.py +++ b/api/subsource.py @@ -20,7 +20,9 @@ class SubSourceDownloader: """SubSource subtitle downloader.""" - def __init__(self, api_url: str, download_dir: str, bazarr=None): + def __init__( + self, api_url: str, download_dir: str, bazarr=None, cf_clearance: str = None + ): self.api_url = api_url self.download_dir = download_dir self.session = requests.Session() @@ -50,11 +52,14 @@ def __init__(self, api_url: str, download_dir: str, bazarr=None): } ) - # Set Cloudflare clearance cookie if available from environment - cf_clearance = os.environ.get("SUBSOURCE_CF_CLEARANCE") - if cf_clearance: - self.session.cookies.set("cf_clearance", cf_clearance) - logger.info("Using Cloudflare clearance cookie from environment") + # Set Cloudflare clearance cookie (priority: env var > config parameter) + cf_clearance_cookie = os.environ.get("SUBSOURCE_CF_CLEARANCE") or cf_clearance + if cf_clearance_cookie: + self.session.cookies.set("cf_clearance", cf_clearance_cookie) + source = ( + "environment" if os.environ.get("SUBSOURCE_CF_CLEARANCE") else "config" + ) + logger.info(f"Using Cloudflare clearance cookie from {source}") # Create download directory if it doesn't exist os.makedirs(download_dir, exist_ok=True) diff --git a/core/config.py b/core/config.py index 0e73b28..cdd9410 100644 --- a/core/config.py +++ b/core/config.py @@ -43,6 +43,9 @@ def load_config(): "username": config.get("auth", "username"), "password": config.get("auth", "password"), "subsource_api_url": config.get("subsource", "api_url"), + "subsource_cf_clearance": config.get( + "subsource", "cf_clearance", fallback="" + ), "download_directory": config.get("download", "directory"), "movies_enabled": config.getboolean("movies", "enabled", fallback=True), "episodes_enabled": config.getboolean("episodes", "enabled", fallback=True), @@ -111,7 +114,14 @@ def create_default_config(config_file: Path): # Write remaining sections f.write("[subsource]\n") - f.write("api_url = https://api.subsource.net/v1\n\n") + f.write("api_url = https://api.subsource.net/v1\n") + f.write( + "# Cloudflare clearance cookie (get from browser DevTools > Application > Cookies)\n" + ) + f.write( + "# Leave empty if not needed or set via SUBSOURCE_CF_CLEARANCE environment variable\n" + ) + f.write("cf_clearance = \n\n") f.write("[download]\n") f.write("directory = /tmp/downloaded_subtitles\n\n") diff --git a/run.py b/run.py index 94bf0c9..5de296f 100644 --- a/run.py +++ b/run.py @@ -131,6 +131,7 @@ def main(): config["subsource_api_url"], config["download_directory"], bazarr, # Pass Bazarr client for API calls + cf_clearance=config.get("subsource_cf_clearance"), ) print(f"Download directory: {config['download_directory']}") print("โœ“ SubSource downloader initialized") diff --git a/tests/api/test_subsource.py b/tests/api/test_subsource.py index f09f2ae..2ba17ab 100644 --- a/tests/api/test_subsource.py +++ b/tests/api/test_subsource.py @@ -44,14 +44,13 @@ def test_init(self): # Check that download directory exists self.assertTrue(os.path.exists(self.download_dir)) - # Check session headers + # Check session headers (verify Cloudflare bypass headers are present) expected_headers = { - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" - ), - "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0", + "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", - "Connection": "keep-alive", + "Origin": "https://subsource.net", + "Referer": "https://subsource.net/", } for key, value in expected_headers.items(): self.assertEqual(self.downloader.session.headers[key], value) diff --git a/tests/test_run.py b/tests/test_run.py index 40736c0..5e4bf66 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -158,7 +158,7 @@ def test_main_with_movies_success( # Verify downloader was created mock_downloader_class.assert_called_once_with( - "https://api.test.com", "/tmp", mock_bazarr + "https://api.test.com", "/tmp", mock_bazarr, cf_clearance=None ) # Verify subtitle processing